mirror of
https://github.com/wwiinnddyy/LanMountainDesktop.git
synced 2026-06-21 16:14:28 +08:00
Compare commits
44 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
29bd47986c | ||
|
|
b12c9bf11d | ||
|
|
dd73e02bce | ||
|
|
ed66869c8d | ||
|
|
8403b89a15 | ||
|
|
0ea98c08bf | ||
|
|
54d97e312d | ||
|
|
04b95020bd | ||
|
|
cf08269e15 | ||
|
|
03e4442e74 | ||
|
|
0c8830133a | ||
|
|
131043fe37 | ||
|
|
a2ac302ee7 | ||
|
|
c351a8e7f3 | ||
|
|
21e970c5b6 | ||
|
|
17873f0f43 | ||
|
|
4051b5cd74 | ||
|
|
5be4537b2c | ||
|
|
c5e75244af | ||
|
|
6a650873bc | ||
|
|
d004088601 | ||
|
|
a1cc0ee2bf | ||
|
|
313d093257 | ||
|
|
1ef47c780b | ||
|
|
a26b6faace | ||
|
|
b219f109ec | ||
|
|
1ee6e68f33 | ||
|
|
545dee85a7 | ||
|
|
ebe35d6f91 | ||
|
|
63f08987a7 | ||
|
|
ce41fd676c | ||
|
|
c1f148f7d6 | ||
|
|
a75ed0ced1 | ||
|
|
2dc40c53e2 | ||
|
|
a99ed9fef2 | ||
|
|
553cee54f9 | ||
|
|
1d7a878d55 | ||
|
|
0361b83ea2 | ||
|
|
cc85638a37 | ||
|
|
791e38d55e | ||
|
|
75aed3f6ad | ||
|
|
01cf32a610 | ||
|
|
69bcf2c6eb | ||
|
|
7a70476ce8 |
432
.cursor/plans/launcher_单项目解耦_302f1ec6.plan.md
Normal file
432
.cursor/plans/launcher_单项目解耦_302f1ec6.plan.md
Normal 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 A:Startup 诊断 + HostStartupMonitor 独立类 + AOT 启动检测竞态修复 + 测试
|
||||||
|
status: completed
|
||||||
|
- id: phase-b-directory
|
||||||
|
content: Phase B1:职责域目录迁移(Deployment/Update/Startup/Oobe/Plugins/Infrastructure),零逻辑变更,提交
|
||||||
|
status: completed
|
||||||
|
- id: phase-b-pipeline
|
||||||
|
content: Phase B2:RunAsync→LaunchPipeline+ILaunchPhase,引入 LauncherOrchestrator,删除 LauncherFlowCoordinator,提交
|
||||||
|
status: completed
|
||||||
|
- id: phase-b-app-slim
|
||||||
|
content: Phase B3:App.axaml.cs 精简为纯 Avalonia 初始化 + 委托 LauncherOrchestrator,提交
|
||||||
|
status: completed
|
||||||
|
- id: phase-c-di
|
||||||
|
content: Phase C:LauncherServiceRegistration + 轻量 MS DI,统一 CLI/GUI 装配,提交
|
||||||
|
status: completed
|
||||||
|
- id: phase-d-update-split
|
||||||
|
content: Phase D:UpdateEngineService→门面+策略类(Verifier/Activator/Rollback 等),提交
|
||||||
|
status: completed
|
||||||
|
- id: phase-e-guardrails
|
||||||
|
content: Phase E:LauncherArchitectureTests + 文档 + AOT 回归,提交
|
||||||
|
status: completed
|
||||||
|
isProject: false
|
||||||
|
---
|
||||||
|
|
||||||
|
# Launcher 单项目内部解耦改造计划(执行版)
|
||||||
|
|
||||||
|
## 0. 硬性约束
|
||||||
|
|
||||||
|
|
||||||
|
| 约束 | 说明 |
|
||||||
|
| ------------ | --------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
||||||
|
| **单项目** | 仅 `[LanMountainDesktop.Launcher/LanMountainDesktop.Launcher.csproj](LanMountainDesktop.Launcher/LanMountainDesktop.Launcher.csproj)`,不新建 Launcher.* 独立程序集 |
|
||||||
|
| **单 exe** | 仍只发布 `LanMountainDesktop.Launcher.exe`(AOT 单文件) |
|
||||||
|
| **零部署风险** | 不改变安装包目录结构、不引入新进程、不改变 Public IPC / Coordinator IPC 拓扑与契约 |
|
||||||
|
| **增量重构** | 一个职责域一域推进,每步 `dotnet build` + 相关 `dotnet test` 通过后再进下一步 |
|
||||||
|
| **单进程性能** | 模块间仅 in-process 接口调用,不为解耦新增 IPC |
|
||||||
|
| **未来可拆** | 各域暴露 `I`* 接口,将来若需多进程可直接复用契约 |
|
||||||
|
| **Git 自主提交** | Agent 在每个职责域完成且验证通过后 **自动 commit**,无需用户手动提交(见 §8) |
|
||||||
|
|
||||||
|
|
||||||
|
外部共享库 `[LanMountainDesktop.PluginPackaging](LanMountainDesktop.PluginPackaging/)` 保留(Host + Launcher CLI 共用),不属于 Launcher 拆分。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 1. 验收标准(必须全部满足)
|
||||||
|
|
||||||
|
### 1.1 零部署风险
|
||||||
|
|
||||||
|
- Inno Setup / CI 产物仍只有:`LanMountainDesktop.Launcher.exe` + `app-{version}/` + `.launcher/`
|
||||||
|
- Host 调用 Launcher 的 CLI 参数、`launch-source`、`apply-update` 路径不变
|
||||||
|
- Public IPC routes(`lanmountain.launcher.startup-progress`、`loading-state`)与 Coordinator pipe 不变
|
||||||
|
- VeloPack / 更新 apply 状态机(`.current/.partial/.destroy`)行为不变
|
||||||
|
|
||||||
|
### 1.2 增量可验证
|
||||||
|
|
||||||
|
- 每个 Phase 结束:编译绿 + 该域新增/既有测试绿
|
||||||
|
- 允许「纯移动文件」的 PR 单独提交,行为 diff 为零
|
||||||
|
|
||||||
|
### 1.3 测试友好
|
||||||
|
|
||||||
|
- `Startup/`、`Update/`、`Deployment/` 内类型 **无 Avalonia 依赖**,可独立单元测试
|
||||||
|
- 每个 `ILaunchPhase`、每个 Update 策略类各有对应测试类
|
||||||
|
- 保留并扩展现有 `[LauncherStartupTimeoutPolicyTests](LanMountainDesktop.Tests/LauncherStartupTimeoutPolicyTests.cs)`、`[LauncherMultiInstancePolicyTests](LanMountainDesktop.Tests/LauncherMultiInstancePolicyTests.cs)`
|
||||||
|
|
||||||
|
### 1.4 启动性能
|
||||||
|
|
||||||
|
- Pipeline 阶段为同步/异步方法调用链,不引入额外进程或网络
|
||||||
|
- DI 容器仅在进程入口构建一次;Stage/Phase 实例可复用 Singleton
|
||||||
|
|
||||||
|
### 1.5 代码结构目标
|
||||||
|
|
||||||
|
|
||||||
|
| 对象 | 当前(实测) | 目标 |
|
||||||
|
| ----------------------------------- | -------------------------------------------- | --------------------------------------------------- |
|
||||||
|
| `LauncherFlowCoordinator` 全 partial | ~1880 行(859+568+279+…) | **删除**;逻辑迁入 Pipeline + Phases |
|
||||||
|
| `RunAsync()` 等价逻辑 | 跨 partial ~800+ 行 while/阶段混杂 | **≤80 行** 编排入口,细节在各 Phase |
|
||||||
|
| `UpdateEngineService` | ~1622 行 | 门面 **≤200 行** + 6 个策略类各 **≤300 行** |
|
||||||
|
| `App.axaml.cs` | ~258 行(已部分瘦身) | **≤120 行**:纯 Avalonia + 一行委托 `LauncherOrchestrator` |
|
||||||
|
| `LauncherOrchestrator` | 不存在(逻辑在 Coordinator + CompositionRoot 546 行) | **≤250 行**:GUI 入口编排 |
|
||||||
|
| `LauncherCompositionRoot` | ~546 行 | **≤150 行**:仅 DI 构建 + 入口分发 |
|
||||||
|
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 2. 目标架构
|
||||||
|
|
||||||
|
### 2.1 核心类型关系
|
||||||
|
|
||||||
|
```mermaid
|
||||||
|
flowchart TB
|
||||||
|
Program --> EntryRouter
|
||||||
|
App --> LauncherOrchestrator
|
||||||
|
EntryRouter --> LauncherOrchestrator
|
||||||
|
LauncherOrchestrator --> LaunchPipeline
|
||||||
|
LaunchPipeline --> Phase1[CleanupPhase]
|
||||||
|
LaunchPipeline --> Phase2[OobeGatePhase]
|
||||||
|
LaunchPipeline --> Phase3[ApplyUpdatePhase]
|
||||||
|
LaunchPipeline --> Phase4[LaunchHostPhase]
|
||||||
|
LaunchPipeline --> Phase5[MonitorStartupPhase]
|
||||||
|
Phase3 --> IUpdateEngine
|
||||||
|
Phase4 --> IDeploymentLocator
|
||||||
|
Phase5 --> IHostStartupMonitor
|
||||||
|
LauncherCompositionRoot --> ServiceProvider
|
||||||
|
ServiceProvider --> LaunchPipeline
|
||||||
|
```
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
**命名约定:**
|
||||||
|
|
||||||
|
- `**LauncherOrchestrator`**:GUI 生命周期内的唯一编排入口(取代 `LauncherFlowCoordinator` 对外角色)
|
||||||
|
- `**LaunchPipeline**`:按序执行 `ILaunchPhase` 列表
|
||||||
|
- `**ILaunchPhase**`:原 `ILaunchPipelineStage`;每个 Phase 对应原 `RunAsync` 中一个职责段
|
||||||
|
|
||||||
|
### 2.2 职责域目录(单项目内)
|
||||||
|
|
||||||
|
```
|
||||||
|
LanMountainDesktop.Launcher/
|
||||||
|
├── Program.cs # CLI / GUI 路由
|
||||||
|
├── App.axaml.cs # 纯 Avalonia(≤120 行)
|
||||||
|
├── Shell/
|
||||||
|
│ ├── LauncherOrchestrator.cs # GUI 编排入口
|
||||||
|
│ ├── LauncherCompositionRoot.cs # DI + Entry 分发
|
||||||
|
│ ├── LaunchPipeline.cs
|
||||||
|
│ ├── Phases/ # ILaunchPhase 实现
|
||||||
|
│ │ ├── CleanupDeploymentsPhase.cs
|
||||||
|
│ │ ├── OobeGatePhase.cs
|
||||||
|
│ │ ├── ApplyPendingUpdatePhase.cs
|
||||||
|
│ │ ├── LaunchHostPhase.cs
|
||||||
|
│ │ └── MonitorStartupPhase.cs
|
||||||
|
│ └── EntryHandlers/ # apply-update / air-app-broker / attach
|
||||||
|
├── Deployment/
|
||||||
|
├── Update/
|
||||||
|
│ ├── IUpdateEngine.cs
|
||||||
|
│ ├── UpdateEngineFacade.cs # 原 UpdateEngineService 门面
|
||||||
|
│ └── Strategies/
|
||||||
|
│ ├── PendingUpdateDetector.cs
|
||||||
|
│ ├── UpdatePackageVerifier.cs
|
||||||
|
│ ├── DeploymentActivator.cs
|
||||||
|
│ ├── UpdateSnapshotStore.cs
|
||||||
|
│ ├── RollbackStrategy.cs
|
||||||
|
│ └── IncomingArtifactsCleaner.cs
|
||||||
|
├── Startup/
|
||||||
|
├── Oobe/
|
||||||
|
├── Ipc/
|
||||||
|
├── AirApp/
|
||||||
|
├── Plugins/
|
||||||
|
├── Infrastructure/
|
||||||
|
├── Models/
|
||||||
|
└── Views/
|
||||||
|
```
|
||||||
|
|
||||||
|
### 2.3 模块依赖规则
|
||||||
|
|
||||||
|
- `Deployment/`、`Update/`、`Startup/`:**禁止** `using Avalonia`
|
||||||
|
- `Views/`:**禁止** 引用具体 `UpdateEngineFacade` / `DeploymentLocator`(仅接口或 Orchestrator)
|
||||||
|
- 跨域:**仅通过 `I`* 接口**;Orchestrator/Pipeline 负责装配
|
||||||
|
|
||||||
|
### 2.4 与 Host 边界(不变)
|
||||||
|
|
||||||
|
|
||||||
|
| 能力 | Owner |
|
||||||
|
| -------------------------- | ------------------------------ |
|
||||||
|
| OOBE / Splash / 多实例 / 启动检测 | Launcher `Startup/` + `Shell/` |
|
||||||
|
| 更新 apply / rollback | Launcher `Update/` |
|
||||||
|
| 插件市场 / pending | Host + PluginPackaging |
|
||||||
|
| 更新 download | Host → spawn Launcher apply |
|
||||||
|
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 3. 三大核心拆分(用户指定)
|
||||||
|
|
||||||
|
### 3.1 拆分 `LauncherFlowCoordinator`:`RunAsync` → Pipeline + Phase
|
||||||
|
|
||||||
|
**现状:** 逻辑分散在 4 个 partial,等效一个 1800+ 行 God Class;`RunAsync` 内含清理、OOBE、更新、启动、IPC 监听、超时 while-loop、多实例分支。
|
||||||
|
|
||||||
|
**目标 API(单项目 `Shell/` 内):**
|
||||||
|
|
||||||
|
```csharp
|
||||||
|
internal interface ILaunchPhase
|
||||||
|
{
|
||||||
|
string PhaseId { get; }
|
||||||
|
/// <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 A:Startup 子系统 + AOT 生产 bug(优先)
|
||||||
|
|
||||||
|
- 抽出 `Startup/HostStartupMonitor.cs`(从 partial 独立)
|
||||||
|
- 修复 IPC 连接退避、成功判定统一走 `StartupSuccessTracker`
|
||||||
|
- Host 侧 `DesktopVisible` 上报对齐(仅日志/时序,不改 IPC 契约)
|
||||||
|
- 测试 + `**git commit**`: `fix(launcher): extract HostStartupMonitor and harden startup detection`
|
||||||
|
|
||||||
|
### Phase B1:目录迁移(零逻辑变更)
|
||||||
|
|
||||||
|
- 物理移动文件到 `Deployment/`、`Update/`、`Startup/` 等,更新 namespace
|
||||||
|
- `dotnet build` + test
|
||||||
|
- `**git commit**`: `refactor(launcher): reorganize into responsibility folders`
|
||||||
|
|
||||||
|
### Phase B2:Pipeline + Phase + LauncherOrchestrator
|
||||||
|
|
||||||
|
- 实现 `ILaunchPhase`、`LaunchPipeline`、`LauncherOrchestrator`
|
||||||
|
- 逐 Phase 从 Coordinator 迁移逻辑(可先并行运行对照测试)
|
||||||
|
- 删除 `LauncherFlowCoordinator*`
|
||||||
|
- `**git commit**`: `refactor(launcher): replace LauncherFlowCoordinator with LaunchPipeline`
|
||||||
|
|
||||||
|
### Phase B3:App.axaml.cs 精简
|
||||||
|
|
||||||
|
- EntryHandlers 提取;App 仅 Avalonia + Orchestrator 委托
|
||||||
|
- `**git commit**`: `refactor(launcher): slim App.axaml.cs to Avalonia shell only`
|
||||||
|
|
||||||
|
### Phase C:轻量 DI
|
||||||
|
|
||||||
|
- `LauncherServiceRegistration.cs` + `Microsoft.Extensions.DependencyInjection`
|
||||||
|
- Program / CliHost / CompositionRoot 统一 `ServiceProvider`
|
||||||
|
- `**git commit**`: `refactor(launcher): add composition-root DI wiring`
|
||||||
|
|
||||||
|
### Phase D:UpdateEngine 策略拆分(可与 B2 并行,依赖 B1)
|
||||||
|
|
||||||
|
- 策略类提取 + `UpdateEngineFacade`
|
||||||
|
- 删除原巨型 `UpdateEngineService.cs`
|
||||||
|
- 每策略测试
|
||||||
|
- `**git commit**`: `refactor(launcher): split UpdateEngine into strategy classes`
|
||||||
|
|
||||||
|
### Phase E:守卫 + 文档 + AOT 回归
|
||||||
|
|
||||||
|
- `LauncherArchitectureTests`(命名空间依赖规则)
|
||||||
|
- 更新 `[docs/LAUNCHER.md](docs/LAUNCHER.md)`、`[.trae/specs/launcher-shell-hardening/spec.md](.trae/specs/launcher-shell-hardening/spec.md)`
|
||||||
|
- AOT publish 本地 smoke:launch / apply-update / 多实例 / 启动检测
|
||||||
|
- `**git commit**`: `docs(launcher): document module boundaries and add architecture tests`
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 5. Phase / Service 测试矩阵
|
||||||
|
|
||||||
|
|
||||||
|
| 组件 | 测试文件 | 覆盖点 |
|
||||||
|
| ----------------------- | ---------------------------- | --------------------------------- |
|
||||||
|
| `StartupSuccessTracker` | `StartupSuccessTrackerTests` | Foreground/Tray/Background policy |
|
||||||
|
| `HostStartupMonitor` | `HostStartupMonitorTests` | 超时、IPC 延迟、ShellStatus 轮询 |
|
||||||
|
| `LaunchPipeline` | `LaunchPipelineTests` | Phase 短路、失败传播 |
|
||||||
|
| 各 `ILaunchPhase` | `*PhaseTests` | 单阶段 mock |
|
||||||
|
| `PendingUpdateDetector` | `PendingUpdateDetectorTests` | 无 pending / corrupt |
|
||||||
|
| `DeploymentActivator` | `DeploymentActivatorTests` | 标记文件状态机 |
|
||||||
|
| `RollbackStrategy` | `RollbackStrategyTests` | 快照回退 |
|
||||||
|
| 命名空间规则 | `LauncherArchitectureTests` | 无 Avalonia 泄漏 |
|
||||||
|
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 6. 明确不做
|
||||||
|
|
||||||
|
- 不新建 csproj(Launcher.Deployment 等)
|
||||||
|
- 不新建 exe / Windows Service
|
||||||
|
- 不改变 Public IPC / Coordinator IPC 协议
|
||||||
|
- 不把插件市场安装迁回 Launcher
|
||||||
|
- 不为模块间通信引入新 IPC(仅保留现有 Host↔Launcher 契约)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 7. 风险与缓解
|
||||||
|
|
||||||
|
|
||||||
|
| 风险 | 缓解 |
|
||||||
|
| --------------- | ------------------------------------------------------------------ |
|
||||||
|
| 大规模移动 merge 冲突 | B1 独立 commit,零逻辑变更 |
|
||||||
|
| Pipeline 迁移行为回归 | 先写 Phase 级测试再迁代码;保留 `LMD_LAUNCHER_LEGACY_COORDINATOR=1` 开关一个版本(可选) |
|
||||||
|
| AOT + DI | 显式注册,禁止反射扫描;`PublishAot` CI 步骤验证 |
|
||||||
|
| Update 拆分遗漏路径 | CLI `update *` 与 GUI apply-update 同一 `IUpdateEngine` 门面 |
|
||||||
|
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 8. Git 工作流(Agent 自主提交)
|
||||||
|
|
||||||
|
**原则:** 每个 Phase 验证通过后立即提交;不累积巨型 uncommitted diff。
|
||||||
|
|
||||||
|
**Commit 前检查(每个 commit 必做):**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
dotnet build LanMountainDesktop.slnx -c Debug
|
||||||
|
dotnet test LanMountainDesktop.slnx -c Debug --filter "FullyQualifiedName~Launcher"
|
||||||
|
```
|
||||||
|
|
||||||
|
**Commit message 风格(与仓库一致):**
|
||||||
|
|
||||||
|
```
|
||||||
|
refactor(launcher): replace LauncherFlowCoordinator with LaunchPipeline
|
||||||
|
|
||||||
|
Pipeline + Phase pattern; LauncherOrchestrator becomes GUI entry.
|
||||||
|
No deployment or IPC contract changes.
|
||||||
|
```
|
||||||
|
|
||||||
|
**禁止:** `git push --force`、修改 git config、跳过 hooks(除非 hook 失败需修复后新 commit)。
|
||||||
|
|
||||||
|
**建议分支:** `refactor/launcher-internal-modularization`(单 long-lived 分支,按 Phase 连续 commit;或每 Phase 一个 PR 由用户决定 merge 时机)。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 9. 整体完成定义(Definition of Done)
|
||||||
|
|
||||||
|
- 无 `LauncherFlowCoordinator` 源文件
|
||||||
|
- `App.axaml.cs` ≤120 行,仅 Avalonia + Orchestrator 委托
|
||||||
|
- `UpdateEngineService` 巨型文件已替换为 Facade + Strategies
|
||||||
|
- 职责域目录就位,架构测试通过
|
||||||
|
- 全量 Launcher 相关测试 + AOT publish smoke 通过
|
||||||
|
- 安装包结构与 IPC 拓扑与重构前一致
|
||||||
|
- 每个 Phase 有对应 Git commit,工作区 clean
|
||||||
|
|
||||||
381
.github/workflows/ddss-publish.yml
vendored
381
.github/workflows/ddss-publish.yml
vendored
@@ -1,381 +0,0 @@
|
|||||||
name: DDSS
|
|
||||||
|
|
||||||
concurrency:
|
|
||||||
group: ddss-${{ github.event_name }}-${{ github.event.workflow_run.id || github.event.inputs.tag || github.run_id }}
|
|
||||||
cancel-in-progress: false
|
|
||||||
|
|
||||||
on:
|
|
||||||
workflow_run:
|
|
||||||
workflows:
|
|
||||||
- PLONDS
|
|
||||||
types:
|
|
||||||
- completed
|
|
||||||
workflow_dispatch:
|
|
||||||
inputs:
|
|
||||||
tag:
|
|
||||||
description: 'Release tag'
|
|
||||||
required: true
|
|
||||||
type: string
|
|
||||||
|
|
||||||
env:
|
|
||||||
DOTNET_VERSION: '10.0.x'
|
|
||||||
|
|
||||||
jobs:
|
|
||||||
publish:
|
|
||||||
if: ${{ github.event_name != 'workflow_run' || github.event.workflow_run.conclusion == 'success' }}
|
|
||||||
runs-on: ubuntu-latest
|
|
||||||
permissions:
|
|
||||||
contents: write
|
|
||||||
actions: read
|
|
||||||
|
|
||||||
steps:
|
|
||||||
- name: Checkout
|
|
||||||
uses: actions/checkout@v4
|
|
||||||
with:
|
|
||||||
fetch-depth: 0
|
|
||||||
submodules: recursive
|
|
||||||
|
|
||||||
- name: Resolve release tag and channel
|
|
||||||
env:
|
|
||||||
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
|
||||||
shell: bash
|
|
||||||
run: |
|
|
||||||
set -euo pipefail
|
|
||||||
if [[ "${{ github.event_name }}" == "workflow_dispatch" ]]; then
|
|
||||||
RAW_TAG="${{ github.event.inputs.tag }}"
|
|
||||||
if [[ "$RAW_TAG" == v* ]]; then
|
|
||||||
TAG="$RAW_TAG"
|
|
||||||
else
|
|
||||||
TAG="v$RAW_TAG"
|
|
||||||
fi
|
|
||||||
else
|
|
||||||
gh run download "${{ github.event.workflow_run.id }}" -n plonds-run-metadata -D plonds-run-metadata
|
|
||||||
TAG="$(tr -d '\r\n' < plonds-run-metadata/tag.txt)"
|
|
||||||
fi
|
|
||||||
|
|
||||||
echo "RELEASE_TAG=${TAG}" >> "$GITHUB_ENV"
|
|
||||||
IS_PRERELEASE="$(gh release view "$TAG" --repo "${{ github.repository }}" --json isPrerelease --jq '.isPrerelease')"
|
|
||||||
if [[ "$IS_PRERELEASE" == "true" ]]; then
|
|
||||||
CHANNEL="preview"
|
|
||||||
else
|
|
||||||
CHANNEL="stable"
|
|
||||||
fi
|
|
||||||
echo "RELEASE_CHANNEL=${CHANNEL}" >> "$GITHUB_ENV"
|
|
||||||
echo "DDSS_CHANNEL_POINTER_KEY=lanmountain/update/meta/channels/${CHANNEL}/ddss-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"
|
|
||||||
fi
|
|
||||||
PUBLIC_BASE="${PUBLIC_BASE%/}"
|
|
||||||
echo "S3_PUBLIC_BASE_URL=${PUBLIC_BASE}" >> "$GITHUB_ENV"
|
|
||||||
echo "S3_BASE_URL=${PUBLIC_BASE}/releases/${TAG}/assets" >> "$GITHUB_ENV"
|
|
||||||
|
|
||||||
- name: Setup .NET
|
|
||||||
uses: actions/setup-dotnet@v4
|
|
||||||
with:
|
|
||||||
dotnet-version: ${{ env.DOTNET_VERSION }}
|
|
||||||
dotnet-quality: preview
|
|
||||||
|
|
||||||
- name: Prepare signing key
|
|
||||||
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
|
|
||||||
fi
|
|
||||||
printf '%s' "$KEY" > update-private-key.pem
|
|
||||||
echo "UPDATE_PRIVATE_KEY_PATH=$PWD/update-private-key.pem" >> "$GITHUB_ENV"
|
|
||||||
|
|
||||||
- name: Build PLONDS tool
|
|
||||||
run: dotnet build PenguinLogisticsOnlineNetworkDistributionSystem/src/Plonds.Tool/Plonds.Tool.csproj -c Release
|
|
||||||
|
|
||||||
- name: Download release assets
|
|
||||||
env:
|
|
||||||
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
|
||||||
shell: bash
|
|
||||||
run: |
|
|
||||||
set -euo pipefail
|
|
||||||
mkdir -p release-assets
|
|
||||||
gh release download "$RELEASE_TAG" -D release-assets
|
|
||||||
find release-assets -maxdepth 1 -type f | sort
|
|
||||||
|
|
||||||
- name: Prepare PLONDS static output
|
|
||||||
env:
|
|
||||||
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
|
||||||
shell: bash
|
|
||||||
run: |
|
|
||||||
set -euo pipefail
|
|
||||||
rm -rf plonds-static
|
|
||||||
mkdir -p plonds-static
|
|
||||||
if [[ "${{ github.event_name }}" == "workflow_run" ]]; then
|
|
||||||
gh run download "${{ github.event.workflow_run.id }}" -n plonds-static -D plonds-static || true
|
|
||||||
fi
|
|
||||||
if [[ ! -d plonds-static/repo/sha256 && -f release-assets/plonds-static.zip ]]; then
|
|
||||||
unzip -q release-assets/plonds-static.zip -d plonds-static
|
|
||||||
fi
|
|
||||||
if [[ ! -d plonds-static/repo/sha256 || ! -d plonds-static/meta/channels || ! -d plonds-static/manifests ]]; then
|
|
||||||
echo "PLONDS static output is missing. Run the PLONDS workflow for this release first."
|
|
||||||
exit 1
|
|
||||||
fi
|
|
||||||
|
|
||||||
- name: Upload release assets to Rainyun S3
|
|
||||||
env:
|
|
||||||
AWS_ACCESS_KEY_ID: ${{ secrets.S3_ACCESS_KEY }}
|
|
||||||
AWS_SECRET_ACCESS_KEY: ${{ secrets.S3_SECRET_KEY }}
|
|
||||||
AWS_DEFAULT_REGION: ${{ vars.S3_REGION }}
|
|
||||||
AWS_REGION: ${{ vars.S3_REGION }}
|
|
||||||
S3_ENDPOINT: ${{ vars.S3_ENDPOINT }}
|
|
||||||
S3_BUCKET: ${{ vars.S3_BUCKET }}
|
|
||||||
shell: bash
|
|
||||||
run: |
|
|
||||||
set -euo pipefail
|
|
||||||
aws --version
|
|
||||||
for file in release-assets/*; do
|
|
||||||
[[ -f "$file" ]] || continue
|
|
||||||
name="$(basename "$file")"
|
|
||||||
if [[ "$name" == "ddss.json" || "$name" == "ddss.json.sig" ]]; then
|
|
||||||
continue
|
|
||||||
fi
|
|
||||||
key="lanmountain/update/releases/${RELEASE_TAG}/assets/${name}"
|
|
||||||
sha256="$(sha256sum "$file" | awk '{print $1}')"
|
|
||||||
existing_sha="$(aws --endpoint-url "$S3_ENDPOINT" --region "$AWS_REGION" s3api head-object --bucket "$S3_BUCKET" --key "$key" --query 'Metadata.sha256' --output text 2>/dev/null || true)"
|
|
||||||
if [[ "$existing_sha" == "$sha256" ]]; then
|
|
||||||
echo "Skip existing asset: $name"
|
|
||||||
continue
|
|
||||||
fi
|
|
||||||
aws --endpoint-url "$S3_ENDPOINT" --region "$AWS_REGION" s3api put-object \
|
|
||||||
--bucket "$S3_BUCKET" \
|
|
||||||
--key "$key" \
|
|
||||||
--body "$file" \
|
|
||||||
--metadata "sha256=$sha256"
|
|
||||||
done
|
|
||||||
|
|
||||||
- name: Upload PLONDS static output to Rainyun S3
|
|
||||||
env:
|
|
||||||
AWS_ACCESS_KEY_ID: ${{ secrets.S3_ACCESS_KEY }}
|
|
||||||
AWS_SECRET_ACCESS_KEY: ${{ secrets.S3_SECRET_KEY }}
|
|
||||||
AWS_DEFAULT_REGION: ${{ vars.S3_REGION }}
|
|
||||||
AWS_REGION: ${{ vars.S3_REGION }}
|
|
||||||
S3_ENDPOINT: ${{ vars.S3_ENDPOINT }}
|
|
||||||
S3_BUCKET: ${{ vars.S3_BUCKET }}
|
|
||||||
shell: bash
|
|
||||||
run: |
|
|
||||||
set -euo pipefail
|
|
||||||
aws --endpoint-url "$S3_ENDPOINT" --region "$AWS_REGION" s3 sync \
|
|
||||||
plonds-static/ \
|
|
||||||
"s3://$S3_BUCKET/lanmountain/update/" \
|
|
||||||
--only-show-errors
|
|
||||||
|
|
||||||
- name: Mirror installers to Rainyun S3
|
|
||||||
env:
|
|
||||||
AWS_ACCESS_KEY_ID: ${{ secrets.S3_ACCESS_KEY }}
|
|
||||||
AWS_SECRET_ACCESS_KEY: ${{ secrets.S3_SECRET_KEY }}
|
|
||||||
AWS_DEFAULT_REGION: ${{ vars.S3_REGION }}
|
|
||||||
AWS_REGION: ${{ vars.S3_REGION }}
|
|
||||||
S3_ENDPOINT: ${{ vars.S3_ENDPOINT }}
|
|
||||||
S3_BUCKET: ${{ vars.S3_BUCKET }}
|
|
||||||
shell: bash
|
|
||||||
run: |
|
|
||||||
set -euo pipefail
|
|
||||||
version="${RELEASE_TAG#v}"
|
|
||||||
for file in release-assets/*; do
|
|
||||||
[[ -f "$file" ]] || continue
|
|
||||||
name="$(basename "$file")"
|
|
||||||
platform=""
|
|
||||||
case "$name" in
|
|
||||||
*.exe)
|
|
||||||
if [[ "$name" == *x86* ]]; then platform="windows-x86"; else platform="windows-x64"; fi
|
|
||||||
;;
|
|
||||||
*.deb)
|
|
||||||
platform="linux-x64"
|
|
||||||
;;
|
|
||||||
*.dmg)
|
|
||||||
if [[ "$name" == *arm64* ]]; then platform="macos-arm64"; else platform="macos-x64"; fi
|
|
||||||
;;
|
|
||||||
esac
|
|
||||||
[[ -n "$platform" ]] || continue
|
|
||||||
key="lanmountain/update/installers/${platform}/${version}/${name}"
|
|
||||||
sha256="$(sha256sum "$file" | awk '{print $1}')"
|
|
||||||
aws --endpoint-url "$S3_ENDPOINT" --region "$AWS_REGION" s3api put-object \
|
|
||||||
--bucket "$S3_BUCKET" \
|
|
||||||
--key "$key" \
|
|
||||||
--body "$file" \
|
|
||||||
--metadata "sha256=$sha256"
|
|
||||||
done
|
|
||||||
|
|
||||||
- name: Build DDSS manifest
|
|
||||||
shell: bash
|
|
||||||
run: |
|
|
||||||
set -euo pipefail
|
|
||||||
mkdir -p ddss-output
|
|
||||||
dotnet run --project PenguinLogisticsOnlineNetworkDistributionSystem/src/Plonds.Tool/Plonds.Tool.csproj --configuration Release -- \
|
|
||||||
build-ddss \
|
|
||||||
--release-tag "$RELEASE_TAG" \
|
|
||||||
--assets-dir release-assets \
|
|
||||||
--output-dir ddss-output \
|
|
||||||
--private-key "$UPDATE_PRIVATE_KEY_PATH" \
|
|
||||||
--repository "${{ github.repository }}" \
|
|
||||||
--s3-base-url "$S3_BASE_URL"
|
|
||||||
|
|
||||||
- name: Validate DDSS asset references in Rainyun S3
|
|
||||||
env:
|
|
||||||
AWS_ACCESS_KEY_ID: ${{ secrets.S3_ACCESS_KEY }}
|
|
||||||
AWS_SECRET_ACCESS_KEY: ${{ secrets.S3_SECRET_KEY }}
|
|
||||||
AWS_DEFAULT_REGION: ${{ vars.S3_REGION }}
|
|
||||||
AWS_REGION: ${{ vars.S3_REGION }}
|
|
||||||
S3_ENDPOINT: ${{ vars.S3_ENDPOINT }}
|
|
||||||
S3_BUCKET: ${{ vars.S3_BUCKET }}
|
|
||||||
shell: bash
|
|
||||||
run: |
|
|
||||||
set -euo pipefail
|
|
||||||
keys=$(jq -r '.assets[]?.mirrors[]?.url // empty' ddss-output/ddss.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"
|
|
||||||
exit 1
|
|
||||||
fi
|
|
||||||
|
|
||||||
while IFS= read -r key; do
|
|
||||||
[[ -n "$key" ]] || continue
|
|
||||||
aws --endpoint-url "$S3_ENDPOINT" --region "$AWS_REGION" s3api head-object \
|
|
||||||
--bucket "$S3_BUCKET" \
|
|
||||||
--key "$key" >/dev/null
|
|
||||||
done <<< "$keys"
|
|
||||||
|
|
||||||
- name: Upload DDSS 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
|
|
||||||
|
|
||||||
- name: Upload DDSS manifest to Rainyun S3 staging
|
|
||||||
env:
|
|
||||||
AWS_ACCESS_KEY_ID: ${{ secrets.S3_ACCESS_KEY }}
|
|
||||||
AWS_SECRET_ACCESS_KEY: ${{ secrets.S3_SECRET_KEY }}
|
|
||||||
AWS_DEFAULT_REGION: ${{ vars.S3_REGION }}
|
|
||||||
AWS_REGION: ${{ vars.S3_REGION }}
|
|
||||||
S3_ENDPOINT: ${{ vars.S3_ENDPOINT }}
|
|
||||||
S3_BUCKET: ${{ vars.S3_BUCKET }}
|
|
||||||
shell: bash
|
|
||||||
run: |
|
|
||||||
set -euo pipefail
|
|
||||||
for file in ddss-output/ddss.json ddss-output/ddss.json.sig; do
|
|
||||||
name="$(basename "$file")"
|
|
||||||
key="lanmountain/update/releases/${RELEASE_TAG}/assets/${name}"
|
|
||||||
sha256="$(sha256sum "$file" | awk '{print $1}')"
|
|
||||||
aws --endpoint-url "$S3_ENDPOINT" --region "$AWS_REGION" s3api put-object \
|
|
||||||
--bucket "$S3_BUCKET" \
|
|
||||||
--key "$key" \
|
|
||||||
--body "$file" \
|
|
||||||
--metadata "sha256=$sha256"
|
|
||||||
done
|
|
||||||
|
|
||||||
- name: Prepare DDSS channel pointer
|
|
||||||
shell: bash
|
|
||||||
run: |
|
|
||||||
set -euo pipefail
|
|
||||||
pointer_file="ddss-output/ddss-latest.json"
|
|
||||||
cat > "$pointer_file" <<'JSON'
|
|
||||||
{
|
|
||||||
"schemaVersion": 1,
|
|
||||||
"channel": "__CHANNEL__",
|
|
||||||
"releaseTag": "__TAG__",
|
|
||||||
"version": "__VERSION__",
|
|
||||||
"updatedAt": "__UPDATED_AT__",
|
|
||||||
"manifest": {
|
|
||||||
"url": "__MANIFEST_URL__",
|
|
||||||
"signatureUrl": "__SIG_URL__"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
JSON
|
|
||||||
|
|
||||||
manifest_url="${S3_BASE_URL}/ddss.json"
|
|
||||||
sig_url="${S3_BASE_URL}/ddss.json.sig"
|
|
||||||
version="${RELEASE_TAG#v}"
|
|
||||||
updated_at="$(date -u +%Y-%m-%dT%H:%M:%SZ)"
|
|
||||||
|
|
||||||
sed -i "s|__CHANNEL__|${RELEASE_CHANNEL}|g" "$pointer_file"
|
|
||||||
sed -i "s|__TAG__|${RELEASE_TAG}|g" "$pointer_file"
|
|
||||||
sed -i "s|__VERSION__|${version}|g" "$pointer_file"
|
|
||||||
sed -i "s|__UPDATED_AT__|${updated_at}|g" "$pointer_file"
|
|
||||||
sed -i "s|__MANIFEST_URL__|${manifest_url}|g" "$pointer_file"
|
|
||||||
sed -i "s|__SIG_URL__|${sig_url}|g" "$pointer_file"
|
|
||||||
|
|
||||||
jq -e . "$pointer_file" >/dev/null
|
|
||||||
|
|
||||||
- name: Atomically publish DDSS channel pointer
|
|
||||||
env:
|
|
||||||
AWS_ACCESS_KEY_ID: ${{ secrets.S3_ACCESS_KEY }}
|
|
||||||
AWS_SECRET_ACCESS_KEY: ${{ secrets.S3_SECRET_KEY }}
|
|
||||||
AWS_DEFAULT_REGION: ${{ vars.S3_REGION }}
|
|
||||||
AWS_REGION: ${{ vars.S3_REGION }}
|
|
||||||
S3_ENDPOINT: ${{ vars.S3_ENDPOINT }}
|
|
||||||
S3_BUCKET: ${{ vars.S3_BUCKET }}
|
|
||||||
shell: bash
|
|
||||||
run: |
|
|
||||||
set -euo pipefail
|
|
||||||
pointer_file="ddss-output/ddss-latest.json"
|
|
||||||
staging_key="lanmountain/update/releases/${RELEASE_TAG}/assets/ddss-latest.json"
|
|
||||||
|
|
||||||
aws --endpoint-url "$S3_ENDPOINT" --region "$AWS_REGION" s3api put-object \
|
|
||||||
--bucket "$S3_BUCKET" \
|
|
||||||
--key "$staging_key" \
|
|
||||||
--body "$pointer_file"
|
|
||||||
|
|
||||||
aws --endpoint-url "$S3_ENDPOINT" --region "$AWS_REGION" s3api put-object \
|
|
||||||
--bucket "$S3_BUCKET" \
|
|
||||||
--key "$DDSS_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
|
|
||||||
|
|
||||||
curl -fsSI "$S3_PUBLIC_BASE_URL/meta/channels/${RELEASE_CHANNEL}/ddss-latest.json" >/dev/null
|
|
||||||
|
|
||||||
- name: Verify Rainyun S3 PLONDS output
|
|
||||||
env:
|
|
||||||
AWS_ACCESS_KEY_ID: ${{ secrets.S3_ACCESS_KEY }}
|
|
||||||
AWS_SECRET_ACCESS_KEY: ${{ secrets.S3_SECRET_KEY }}
|
|
||||||
AWS_DEFAULT_REGION: ${{ vars.S3_REGION }}
|
|
||||||
AWS_REGION: ${{ vars.S3_REGION }}
|
|
||||||
S3_ENDPOINT: ${{ vars.S3_ENDPOINT }}
|
|
||||||
S3_BUCKET: ${{ vars.S3_BUCKET }}
|
|
||||||
shell: bash
|
|
||||||
run: |
|
|
||||||
set -euo pipefail
|
|
||||||
mapfile -t required < <(
|
|
||||||
{
|
|
||||||
find plonds-static/meta/channels -path '*/latest.json' -type f | sort | head -n 1
|
|
||||||
find plonds-static/meta/distributions -name '*.json' -type f | sort | head -n 1
|
|
||||||
find plonds-static/manifests -name 'plonds-filemap.json' -type f | sort | head -n 1
|
|
||||||
find plonds-static/manifests -name 'plonds-filemap.json.sig' -type f | sort | head -n 1
|
|
||||||
find plonds-static/repo/sha256 -type f | sort | head -n 1
|
|
||||||
} | sed '/^$/d'
|
|
||||||
)
|
|
||||||
|
|
||||||
if [[ "${#required[@]}" -lt 5 ]]; then
|
|
||||||
echo "Not enough PLONDS static files to verify."
|
|
||||||
exit 1
|
|
||||||
fi
|
|
||||||
|
|
||||||
for path in "${required[@]}"; do
|
|
||||||
rel="${path#plonds-static/}"
|
|
||||||
key="lanmountain/update/${rel}"
|
|
||||||
aws --endpoint-url "$S3_ENDPOINT" --region "$AWS_REGION" s3api head-object \
|
|
||||||
--bucket "$S3_BUCKET" \
|
|
||||||
--key "$key" >/dev/null
|
|
||||||
curl -fsSI "$S3_PUBLIC_BASE_URL/$rel" >/dev/null
|
|
||||||
done
|
|
||||||
146
.github/workflows/ddss-rollback.yml
vendored
146
.github/workflows/ddss-rollback.yml
vendored
@@ -1,146 +0,0 @@
|
|||||||
name: DDSS Rollback
|
|
||||||
|
|
||||||
on:
|
|
||||||
workflow_dispatch:
|
|
||||||
inputs:
|
|
||||||
channel:
|
|
||||||
description: 'Target channel to rollback'
|
|
||||||
required: true
|
|
||||||
type: choice
|
|
||||||
default: stable
|
|
||||||
options:
|
|
||||||
- stable
|
|
||||||
- preview
|
|
||||||
target_tag:
|
|
||||||
description: 'Release tag to rollback to (e.g. v1.2.3)'
|
|
||||||
required: true
|
|
||||||
type: string
|
|
||||||
|
|
||||||
env:
|
|
||||||
DOTNET_VERSION: '10.0.x'
|
|
||||||
|
|
||||||
jobs:
|
|
||||||
rollback:
|
|
||||||
runs-on: ubuntu-latest
|
|
||||||
permissions:
|
|
||||||
contents: read
|
|
||||||
|
|
||||||
concurrency:
|
|
||||||
group: ddss-rollback-${{ github.event.inputs.channel }}
|
|
||||||
cancel-in-progress: false
|
|
||||||
|
|
||||||
steps:
|
|
||||||
- name: Checkout
|
|
||||||
uses: actions/checkout@v4
|
|
||||||
with:
|
|
||||||
fetch-depth: 0
|
|
||||||
|
|
||||||
- name: Resolve rollback context
|
|
||||||
env:
|
|
||||||
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
|
||||||
shell: bash
|
|
||||||
run: |
|
|
||||||
set -euo pipefail
|
|
||||||
|
|
||||||
RAW_TAG="${{ github.event.inputs.target_tag }}"
|
|
||||||
if [[ "$RAW_TAG" == v* ]]; then
|
|
||||||
TAG="$RAW_TAG"
|
|
||||||
else
|
|
||||||
TAG="v$RAW_TAG"
|
|
||||||
fi
|
|
||||||
|
|
||||||
CHANNEL="${{ github.event.inputs.channel }}"
|
|
||||||
|
|
||||||
gh release view "$TAG" --repo "${{ github.repository }}" --json tagName >/dev/null
|
|
||||||
|
|
||||||
PUBLIC_BASE="${{ vars.S3_PUBLIC_BASE_URL }}"
|
|
||||||
if [[ -z "$PUBLIC_BASE" ]]; then
|
|
||||||
PUBLIC_BASE="https://cn-nb1.rains3.com/lmdesktop/lanmountain/update"
|
|
||||||
fi
|
|
||||||
PUBLIC_BASE="${PUBLIC_BASE%/}"
|
|
||||||
|
|
||||||
echo "RELEASE_TAG=${TAG}" >> "$GITHUB_ENV"
|
|
||||||
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"
|
|
||||||
|
|
||||||
- name: Validate rollback target assets
|
|
||||||
env:
|
|
||||||
AWS_ACCESS_KEY_ID: ${{ secrets.S3_ACCESS_KEY }}
|
|
||||||
AWS_SECRET_ACCESS_KEY: ${{ secrets.S3_SECRET_KEY }}
|
|
||||||
AWS_DEFAULT_REGION: ${{ vars.S3_REGION }}
|
|
||||||
AWS_REGION: ${{ vars.S3_REGION }}
|
|
||||||
S3_ENDPOINT: ${{ vars.S3_ENDPOINT }}
|
|
||||||
S3_BUCKET: ${{ vars.S3_BUCKET }}
|
|
||||||
shell: bash
|
|
||||||
run: |
|
|
||||||
set -euo pipefail
|
|
||||||
|
|
||||||
for name in ddss.json ddss.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" \
|
|
||||||
--key "$key" >/dev/null
|
|
||||||
done
|
|
||||||
|
|
||||||
- name: Build rollback pointer
|
|
||||||
shell: bash
|
|
||||||
run: |
|
|
||||||
set -euo pipefail
|
|
||||||
|
|
||||||
mkdir -p rollback-output
|
|
||||||
pointer_file="rollback-output/ddss-latest.json"
|
|
||||||
|
|
||||||
manifest_url="${S3_BASE_URL}/ddss.json"
|
|
||||||
sig_url="${S3_BASE_URL}/ddss.json.sig"
|
|
||||||
version="${RELEASE_TAG#v}"
|
|
||||||
updated_at="$(date -u +%Y-%m-%dT%H:%M:%SZ)"
|
|
||||||
|
|
||||||
cat > "$pointer_file" <<EOF
|
|
||||||
{
|
|
||||||
"schemaVersion": 1,
|
|
||||||
"channel": "${RELEASE_CHANNEL}",
|
|
||||||
"releaseTag": "${RELEASE_TAG}",
|
|
||||||
"version": "${version}",
|
|
||||||
"updatedAt": "${updated_at}",
|
|
||||||
"manifest": {
|
|
||||||
"url": "${manifest_url}",
|
|
||||||
"signatureUrl": "${sig_url}"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
EOF
|
|
||||||
|
|
||||||
jq -e . "$pointer_file" >/dev/null
|
|
||||||
|
|
||||||
- name: Publish rollback pointer
|
|
||||||
env:
|
|
||||||
AWS_ACCESS_KEY_ID: ${{ secrets.S3_ACCESS_KEY }}
|
|
||||||
AWS_SECRET_ACCESS_KEY: ${{ secrets.S3_SECRET_KEY }}
|
|
||||||
AWS_DEFAULT_REGION: ${{ vars.S3_REGION }}
|
|
||||||
AWS_REGION: ${{ vars.S3_REGION }}
|
|
||||||
S3_ENDPOINT: ${{ vars.S3_ENDPOINT }}
|
|
||||||
S3_BUCKET: ${{ vars.S3_BUCKET }}
|
|
||||||
shell: bash
|
|
||||||
run: |
|
|
||||||
set -euo pipefail
|
|
||||||
|
|
||||||
pointer_file="rollback-output/ddss-latest.json"
|
|
||||||
|
|
||||||
aws --endpoint-url "$S3_ENDPOINT" --region "$AWS_REGION" s3api put-object \
|
|
||||||
--bucket "$S3_BUCKET" \
|
|
||||||
--key "$DDSS_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
|
|
||||||
|
|
||||||
curl -fsSI "$S3_PUBLIC_BASE_URL/meta/channels/${RELEASE_CHANNEL}/ddss-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"
|
|
||||||
278
.github/workflows/plonds-build.yml
vendored
278
.github/workflows/plonds-build.yml
vendored
@@ -1,278 +0,0 @@
|
|||||||
name: PLONDS
|
|
||||||
|
|
||||||
concurrency:
|
|
||||||
group: plonds-${{ github.event_name }}-${{ github.event.release.tag_name || github.event.inputs.tag || github.run_id }}
|
|
||||||
cancel-in-progress: false
|
|
||||||
|
|
||||||
on:
|
|
||||||
release:
|
|
||||||
types:
|
|
||||||
- published
|
|
||||||
- prereleased
|
|
||||||
- edited
|
|
||||||
workflow_dispatch:
|
|
||||||
inputs:
|
|
||||||
tag:
|
|
||||||
description: 'Release tag'
|
|
||||||
required: true
|
|
||||||
type: string
|
|
||||||
baseline_tag:
|
|
||||||
description: 'Optional baseline tag'
|
|
||||||
required: false
|
|
||||||
type: string
|
|
||||||
channel:
|
|
||||||
description: 'Update channel'
|
|
||||||
required: false
|
|
||||||
type: choice
|
|
||||||
default: stable
|
|
||||||
options:
|
|
||||||
- stable
|
|
||||||
- preview
|
|
||||||
|
|
||||||
env:
|
|
||||||
DOTNET_VERSION: '10.0.x'
|
|
||||||
|
|
||||||
jobs:
|
|
||||||
build:
|
|
||||||
runs-on: ubuntu-latest
|
|
||||||
permissions:
|
|
||||||
contents: write
|
|
||||||
|
|
||||||
steps:
|
|
||||||
- name: Checkout
|
|
||||||
uses: actions/checkout@v4
|
|
||||||
with:
|
|
||||||
fetch-depth: 0
|
|
||||||
submodules: recursive
|
|
||||||
|
|
||||||
- name: Resolve release context
|
|
||||||
shell: bash
|
|
||||||
run: |
|
|
||||||
if [[ "${{ github.event_name }}" == "release" ]]; then
|
|
||||||
TAG="${{ github.event.release.tag_name }}"
|
|
||||||
if [[ "${{ github.event.release.prerelease }}" == "true" ]]; then
|
|
||||||
CHANNEL="preview"
|
|
||||||
else
|
|
||||||
CHANNEL="stable"
|
|
||||||
fi
|
|
||||||
BASELINE_TAG=""
|
|
||||||
else
|
|
||||||
RAW_TAG="${{ github.event.inputs.tag }}"
|
|
||||||
if [[ "${RAW_TAG}" == v* ]]; then
|
|
||||||
TAG="${RAW_TAG}"
|
|
||||||
else
|
|
||||||
TAG="v${RAW_TAG}"
|
|
||||||
fi
|
|
||||||
CHANNEL="${{ github.event.inputs.channel }}"
|
|
||||||
BASELINE_TAG="${{ github.event.inputs.baseline_tag }}"
|
|
||||||
fi
|
|
||||||
|
|
||||||
echo "RELEASE_TAG=${TAG}" >> "$GITHUB_ENV"
|
|
||||||
echo "RELEASE_VERSION=${TAG#v}" >> "$GITHUB_ENV"
|
|
||||||
echo "RELEASE_CHANNEL=${CHANNEL}" >> "$GITHUB_ENV"
|
|
||||||
echo "BASELINE_TAG_INPUT=${BASELINE_TAG}" >> "$GITHUB_ENV"
|
|
||||||
PUBLIC_BASE="${{ vars.S3_PUBLIC_BASE_URL }}"
|
|
||||||
if [[ -z "$PUBLIC_BASE" ]]; then
|
|
||||||
PUBLIC_BASE="https://cn-nb1.rains3.com/lmdesktop/lanmountain/update"
|
|
||||||
fi
|
|
||||||
echo "S3_PUBLIC_BASE_URL=${PUBLIC_BASE%/}" >> "$GITHUB_ENV"
|
|
||||||
|
|
||||||
- name: Setup .NET
|
|
||||||
uses: actions/setup-dotnet@v4
|
|
||||||
with:
|
|
||||||
dotnet-version: ${{ env.DOTNET_VERSION }}
|
|
||||||
dotnet-quality: preview
|
|
||||||
|
|
||||||
- name: Prepare signing key
|
|
||||||
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
|
|
||||||
fi
|
|
||||||
printf '%s' "$KEY" > update-private-key.pem
|
|
||||||
echo "UPDATE_PRIVATE_KEY_PATH=$PWD/update-private-key.pem" >> "$GITHUB_ENV"
|
|
||||||
|
|
||||||
- name: Build PLONDS tool
|
|
||||||
run: dotnet build PenguinLogisticsOnlineNetworkDistributionSystem/src/Plonds.Tool/Plonds.Tool.csproj -c Release
|
|
||||||
|
|
||||||
- name: Resolve baseline plan
|
|
||||||
env:
|
|
||||||
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
|
||||||
shell: pwsh
|
|
||||||
run: |
|
|
||||||
$ErrorActionPreference = 'Stop'
|
|
||||||
$repo = '${{ github.repository }}'
|
|
||||||
$tag = $env:RELEASE_TAG
|
|
||||||
$baselineInput = $env:BASELINE_TAG_INPUT
|
|
||||||
$currentRelease = gh release view $tag --repo $repo --json tagName,isPrerelease,assets,publishedAt | ConvertFrom-Json
|
|
||||||
$allReleases = gh api "repos/$repo/releases?per_page=100" | ConvertFrom-Json
|
|
||||||
$platforms = @('windows-x64', 'windows-x86', 'linux-x64')
|
|
||||||
|
|
||||||
$entries = foreach ($platform in $platforms) {
|
|
||||||
$assetName = "files-$platform.zip"
|
|
||||||
$currentAsset = $currentRelease.assets | Where-Object { $_.name -eq $assetName } | Select-Object -First 1
|
|
||||||
if (-not $currentAsset) {
|
|
||||||
throw "Current release $tag does not contain required asset $assetName"
|
|
||||||
}
|
|
||||||
|
|
||||||
$baselineRelease = $null
|
|
||||||
if (-not [string]::IsNullOrWhiteSpace($baselineInput)) {
|
|
||||||
$normalizedBaseline = if ($baselineInput.StartsWith('v')) { $baselineInput } else { "v$baselineInput" }
|
|
||||||
$baselineRelease = $allReleases | Where-Object { $_.tag_name -eq $normalizedBaseline } | Select-Object -First 1
|
|
||||||
if (-not $baselineRelease) {
|
|
||||||
throw "Specified baseline tag not found: $normalizedBaseline"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
else {
|
|
||||||
$baselineRelease = $allReleases |
|
|
||||||
Where-Object {
|
|
||||||
$_.tag_name -ne $tag -and
|
|
||||||
-not $_.draft -and
|
|
||||||
[bool]$_.prerelease -eq [bool]$currentRelease.isPrerelease -and
|
|
||||||
($_.assets | Where-Object { $_.name -eq $assetName } | Measure-Object).Count -gt 0
|
|
||||||
} |
|
|
||||||
Select-Object -First 1
|
|
||||||
}
|
|
||||||
|
|
||||||
[pscustomobject]@{
|
|
||||||
platform = $platform
|
|
||||||
assetName = $assetName
|
|
||||||
baselineTag = if ($baselineRelease) { $baselineRelease.tag_name } else { $null }
|
|
||||||
baselineVersion = if ($baselineRelease) { ($baselineRelease.tag_name -replace '^v', '') } else { $null }
|
|
||||||
isFullPayload = -not $baselineRelease
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
$plan = [pscustomobject]@{
|
|
||||||
tag = $tag
|
|
||||||
version = $env:RELEASE_VERSION
|
|
||||||
channel = $env:RELEASE_CHANNEL
|
|
||||||
platforms = $entries
|
|
||||||
}
|
|
||||||
|
|
||||||
$plan | ConvertTo-Json -Depth 8 | Set-Content plonds-plan.json -Encoding utf8
|
|
||||||
Get-Content plonds-plan.json
|
|
||||||
|
|
||||||
- name: Download payload zips
|
|
||||||
env:
|
|
||||||
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
|
||||||
shell: pwsh
|
|
||||||
run: |
|
|
||||||
$ErrorActionPreference = 'Stop'
|
|
||||||
$repo = '${{ github.repository }}'
|
|
||||||
$plan = Get-Content plonds-plan.json | ConvertFrom-Json
|
|
||||||
|
|
||||||
foreach ($entry in $plan.platforms) {
|
|
||||||
$currentDir = Join-Path $PWD "plonds-input/current/$($entry.platform)"
|
|
||||||
New-Item -ItemType Directory -Path $currentDir -Force | Out-Null
|
|
||||||
gh release download $plan.tag --repo $repo -p $entry.assetName -D $currentDir
|
|
||||||
|
|
||||||
if (-not [string]::IsNullOrWhiteSpace($entry.baselineTag)) {
|
|
||||||
$baselineDir = Join-Path $PWD "plonds-input/baseline/$($entry.platform)"
|
|
||||||
New-Item -ItemType Directory -Path $baselineDir -Force | Out-Null
|
|
||||||
gh release download $entry.baselineTag --repo $repo -p $entry.assetName -D $baselineDir
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
- name: Build delta assets
|
|
||||||
shell: pwsh
|
|
||||||
run: |
|
|
||||||
$ErrorActionPreference = 'Stop'
|
|
||||||
$plan = Get-Content plonds-plan.json | ConvertFrom-Json
|
|
||||||
foreach ($entry in $plan.platforms) {
|
|
||||||
$currentZip = Join-Path $PWD "plonds-input/current/$($entry.platform)/$($entry.assetName)"
|
|
||||||
$args = @(
|
|
||||||
'run', '--project', 'PenguinLogisticsOnlineNetworkDistributionSystem/src/Plonds.Tool/Plonds.Tool.csproj', '--configuration', 'Release', '--',
|
|
||||||
'build-delta',
|
|
||||||
'--platform', $entry.platform,
|
|
||||||
'--current-version', $plan.version,
|
|
||||||
'--current-tag', $plan.tag,
|
|
||||||
'--current-zip', $currentZip,
|
|
||||||
'--output-dir', 'plonds-output',
|
|
||||||
'--private-key', $env:UPDATE_PRIVATE_KEY_PATH,
|
|
||||||
'--channel', $plan.channel,
|
|
||||||
'--static-output-dir', 'plonds-output/static',
|
|
||||||
'--update-base-url', $env:S3_PUBLIC_BASE_URL
|
|
||||||
)
|
|
||||||
|
|
||||||
if ([bool]$entry.isFullPayload) {
|
|
||||||
$args += @('--is-full-payload', 'true')
|
|
||||||
}
|
|
||||||
else {
|
|
||||||
$baselineZip = Join-Path $PWD "plonds-input/baseline/$($entry.platform)/$($entry.assetName)"
|
|
||||||
$args += @('--baseline-tag', $entry.baselineTag, '--baseline-version', $entry.baselineVersion, '--baseline-zip', $baselineZip)
|
|
||||||
}
|
|
||||||
|
|
||||||
dotnet @args
|
|
||||||
}
|
|
||||||
|
|
||||||
dotnet run --project PenguinLogisticsOnlineNetworkDistributionSystem/src/Plonds.Tool/Plonds.Tool.csproj --configuration Release -- `
|
|
||||||
build-index `
|
|
||||||
--release-tag $plan.tag `
|
|
||||||
--version $plan.version `
|
|
||||||
--channel $plan.channel `
|
|
||||||
--platform-summaries-dir plonds-output/platform-summaries `
|
|
||||||
--output-dir plonds-output `
|
|
||||||
--private-key $env:UPDATE_PRIVATE_KEY_PATH
|
|
||||||
|
|
||||||
foreach ($entry in $plan.platforms) {
|
|
||||||
$summary = Get-Content "plonds-output/platform-summaries/platform-summary-$($entry.platform).json" | ConvertFrom-Json
|
|
||||||
$required = @(
|
|
||||||
"plonds-output/static/meta/channels/$($plan.channel)/$($entry.platform)/latest.json",
|
|
||||||
"plonds-output/static/meta/distributions/$($summary.distributionId).json",
|
|
||||||
"plonds-output/static/manifests/$($summary.distributionId)/plonds-filemap.json",
|
|
||||||
"plonds-output/static/manifests/$($summary.distributionId)/plonds-filemap.json.sig"
|
|
||||||
)
|
|
||||||
|
|
||||||
foreach ($path in $required) {
|
|
||||||
if (-not (Test-Path $path)) {
|
|
||||||
throw "Missing PLONDS static output: $path"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
$objects = Get-ChildItem -Path "plonds-output/static/repo/sha256" -File -Recurse -ErrorAction SilentlyContinue
|
|
||||||
if (-not $objects -or $objects.Count -eq 0) {
|
|
||||||
throw "PLONDS static object repository is empty."
|
|
||||||
}
|
|
||||||
|
|
||||||
Compress-Archive -Path "plonds-output/static/*" -DestinationPath "plonds-output/release-assets/plonds-static.zip" -Force
|
|
||||||
|
|
||||||
- name: Upload PLONDS assets to release
|
|
||||||
env:
|
|
||||||
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
|
||||||
shell: bash
|
|
||||||
run: |
|
|
||||||
set -euo pipefail
|
|
||||||
gh release upload "$RELEASE_TAG" plonds-output/release-assets/* --clobber
|
|
||||||
|
|
||||||
- name: Persist run metadata
|
|
||||||
shell: bash
|
|
||||||
run: |
|
|
||||||
mkdir -p plonds-run-metadata
|
|
||||||
printf '%s' "$RELEASE_TAG" > plonds-run-metadata/tag.txt
|
|
||||||
|
|
||||||
- name: Upload run metadata artifact
|
|
||||||
uses: actions/upload-artifact@v4
|
|
||||||
with:
|
|
||||||
name: plonds-run-metadata
|
|
||||||
path: plonds-run-metadata/tag.txt
|
|
||||||
if-no-files-found: error
|
|
||||||
retention-days: 7
|
|
||||||
|
|
||||||
- name: Upload PLONDS static artifact
|
|
||||||
uses: actions/upload-artifact@v4
|
|
||||||
with:
|
|
||||||
name: plonds-static
|
|
||||||
path: plonds-output/static/**
|
|
||||||
if-no-files-found: error
|
|
||||||
retention-days: 7
|
|
||||||
258
.github/workflows/plonds-comparator.yml
vendored
Normal file
258
.github/workflows/plonds-comparator.yml
vendored
Normal file
@@ -0,0 +1,258 @@
|
|||||||
|
name: PLONDS Comparator
|
||||||
|
|
||||||
|
concurrency:
|
||||||
|
group: plonds-${{ github.event_name }}-${{ github.event.release.tag_name || github.event.inputs.tag || github.run_id }}
|
||||||
|
cancel-in-progress: false
|
||||||
|
|
||||||
|
on:
|
||||||
|
release:
|
||||||
|
types:
|
||||||
|
- published
|
||||||
|
- prereleased
|
||||||
|
workflow_dispatch:
|
||||||
|
inputs:
|
||||||
|
tag:
|
||||||
|
description: 'Release tag'
|
||||||
|
required: true
|
||||||
|
type: string
|
||||||
|
baseline_tag:
|
||||||
|
description: 'Optional baseline tag (auto-detected if omitted)'
|
||||||
|
required: false
|
||||||
|
type: string
|
||||||
|
channel:
|
||||||
|
description: 'Update channel'
|
||||||
|
required: false
|
||||||
|
type: choice
|
||||||
|
default: stable
|
||||||
|
options:
|
||||||
|
- stable
|
||||||
|
- preview
|
||||||
|
compare_method:
|
||||||
|
description: 'Compare method'
|
||||||
|
required: false
|
||||||
|
type: choice
|
||||||
|
default: file-compare
|
||||||
|
options:
|
||||||
|
- file-compare
|
||||||
|
- commit-analyze
|
||||||
|
hash_algorithm:
|
||||||
|
description: 'Hash algorithm (file-compare only)'
|
||||||
|
required: false
|
||||||
|
type: choice
|
||||||
|
default: sha256
|
||||||
|
options:
|
||||||
|
- sha256
|
||||||
|
- md5
|
||||||
|
|
||||||
|
env:
|
||||||
|
DOTNET_VERSION: '10.0.x'
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
compare:
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
permissions:
|
||||||
|
contents: write
|
||||||
|
|
||||||
|
steps:
|
||||||
|
- name: Checkout
|
||||||
|
uses: actions/checkout@v4
|
||||||
|
with:
|
||||||
|
fetch-depth: 0
|
||||||
|
submodules: recursive
|
||||||
|
|
||||||
|
- name: Resolve release context
|
||||||
|
shell: bash
|
||||||
|
run: |
|
||||||
|
set -euo pipefail
|
||||||
|
if [[ "${{ github.event_name }}" == "release" ]]; then
|
||||||
|
TAG="${{ github.event.release.tag_name }}"
|
||||||
|
if [[ "${{ github.event.release.prerelease }}" == "true" ]]; then
|
||||||
|
CHANNEL="preview"
|
||||||
|
else
|
||||||
|
CHANNEL="stable"
|
||||||
|
fi
|
||||||
|
BASELINE_TAG_INPUT=""
|
||||||
|
COMPARE_METHOD="file-compare"
|
||||||
|
HASH_ALGORITHM="sha256"
|
||||||
|
else
|
||||||
|
RAW_TAG="${{ github.event.inputs.tag }}"
|
||||||
|
if [[ "${RAW_TAG}" == v* ]]; then
|
||||||
|
TAG="${RAW_TAG}"
|
||||||
|
else
|
||||||
|
TAG="v${RAW_TAG}"
|
||||||
|
fi
|
||||||
|
CHANNEL="${{ github.event.inputs.channel }}"
|
||||||
|
BASELINE_TAG_INPUT="${{ github.event.inputs.baseline_tag }}"
|
||||||
|
COMPARE_METHOD="${{ github.event.inputs.compare_method }}"
|
||||||
|
HASH_ALGORITHM="${{ github.event.inputs.hash_algorithm }}"
|
||||||
|
fi
|
||||||
|
|
||||||
|
echo "RELEASE_TAG=${TAG}" >> "$GITHUB_ENV"
|
||||||
|
echo "RELEASE_VERSION=${TAG#v}" >> "$GITHUB_ENV"
|
||||||
|
echo "RELEASE_CHANNEL=${CHANNEL}" >> "$GITHUB_ENV"
|
||||||
|
echo "BASELINE_TAG_INPUT=${BASELINE_TAG_INPUT}" >> "$GITHUB_ENV"
|
||||||
|
echo "COMPARE_METHOD=${COMPARE_METHOD}" >> "$GITHUB_ENV"
|
||||||
|
echo "HASH_ALGORITHM=${HASH_ALGORITHM}" >> "$GITHUB_ENV"
|
||||||
|
|
||||||
|
- name: Setup .NET
|
||||||
|
uses: actions/setup-dotnet@v4
|
||||||
|
with:
|
||||||
|
dotnet-version: ${{ env.DOTNET_VERSION }}
|
||||||
|
dotnet-quality: preview
|
||||||
|
|
||||||
|
- name: Build PLONDS tool
|
||||||
|
run: dotnet build PenguinLogisticsOnlineNetworkDistributionSystem/src/Plonds.Tool/Plonds.Tool.csproj -c Release
|
||||||
|
|
||||||
|
- name: Resolve baseline
|
||||||
|
env:
|
||||||
|
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||||
|
shell: bash
|
||||||
|
run: |
|
||||||
|
set -euo pipefail
|
||||||
|
BASELINE_TAG=""
|
||||||
|
BASELINE_VERSION=""
|
||||||
|
|
||||||
|
if [[ -n "$BASELINE_TAG_INPUT" ]]; then
|
||||||
|
NORMALIZED="$BASELINE_TAG_INPUT"
|
||||||
|
if [[ "$NORMALIZED" != v* ]]; then NORMALIZED="v$NORMALIZED"; fi
|
||||||
|
if gh release view "$NORMALIZED" --repo "${{ github.repository }}" --json tagName >/dev/null 2>&1; then
|
||||||
|
BASELINE_TAG="$NORMALIZED"
|
||||||
|
BASELINE_VERSION="${NORMALIZED#v}"
|
||||||
|
else
|
||||||
|
echo "Specified baseline tag not found: $NORMALIZED"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
else
|
||||||
|
IS_PRERELEASE="$(gh release view "$RELEASE_TAG" --repo "${{ github.repository }}" --json isPrerelease --jq '.isPrerelease')"
|
||||||
|
CANDIDATES="$(gh api "repos/${{ github.repository }}/releases?per_page=50" \
|
||||||
|
--jq ".[] | select(.draft == false and .prerelease == ${IS_PRERELEASE} and .tag_name != \"${RELEASE_TAG}\") | .tag_name")"
|
||||||
|
|
||||||
|
for CANDIDATE in $CANDIDATES; do
|
||||||
|
if gh release download "$CANDIDATE" -p "files-windows-x64.zip" -D /tmp/baseline-check --clobber 2>/dev/null; then
|
||||||
|
BASELINE_TAG="$CANDIDATE"
|
||||||
|
BASELINE_VERSION="${CANDIDATE#v}"
|
||||||
|
rm -rf /tmp/baseline-check
|
||||||
|
break
|
||||||
|
fi
|
||||||
|
done
|
||||||
|
fi
|
||||||
|
|
||||||
|
if [[ -n "$BASELINE_TAG" ]]; then
|
||||||
|
echo "BASELINE_TAG=${BASELINE_TAG}" >> "$GITHUB_ENV"
|
||||||
|
echo "BASELINE_VERSION=${BASELINE_VERSION}" >> "$GITHUB_ENV"
|
||||||
|
echo "Resolved baseline: ${BASELINE_TAG}"
|
||||||
|
else
|
||||||
|
echo "No baseline found. This will be a full update."
|
||||||
|
fi
|
||||||
|
|
||||||
|
- name: Download payload zips
|
||||||
|
env:
|
||||||
|
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||||
|
shell: bash
|
||||||
|
run: |
|
||||||
|
set -euo pipefail
|
||||||
|
mkdir -p plonds-input
|
||||||
|
|
||||||
|
gh release download "$RELEASE_TAG" -p "files-windows-x64.zip" -D plonds-input
|
||||||
|
mv plonds-input/files-windows-x64.zip plonds-input/current-files-windows-x64.zip
|
||||||
|
|
||||||
|
if [[ -n "$BASELINE_TAG" ]]; then
|
||||||
|
gh release download "$BASELINE_TAG" -p "files-windows-x64.zip" -D plonds-input
|
||||||
|
mv plonds-input/files-windows-x64.zip plonds-input/baseline-files-windows-x64.zip
|
||||||
|
fi
|
||||||
|
|
||||||
|
- name: Run build-delta (file-compare)
|
||||||
|
if: env.COMPARE_METHOD == 'file-compare'
|
||||||
|
shell: bash
|
||||||
|
run: |
|
||||||
|
set -euo pipefail
|
||||||
|
mkdir -p plonds-output
|
||||||
|
|
||||||
|
ARGS=(
|
||||||
|
'run' '--project' 'PenguinLogisticsOnlineNetworkDistributionSystem/src/Plonds.Tool/Plonds.Tool.csproj'
|
||||||
|
'--configuration' 'Release' '--'
|
||||||
|
'build-delta'
|
||||||
|
'--platform' 'windows-x64'
|
||||||
|
'--current-version' "$RELEASE_VERSION"
|
||||||
|
'--current-zip' "$PWD/plonds-input/current-files-windows-x64.zip"
|
||||||
|
'--output-dir' "$PWD/plonds-output"
|
||||||
|
'--channel' "$RELEASE_CHANNEL"
|
||||||
|
'--hash-algorithm' "$HASH_ALGORITHM"
|
||||||
|
)
|
||||||
|
|
||||||
|
if [[ -n "$BASELINE_TAG" ]]; then
|
||||||
|
ARGS+=(
|
||||||
|
'--baseline-version' "$BASELINE_VERSION"
|
||||||
|
'--baseline-zip' "$PWD/plonds-input/baseline-files-windows-x64.zip"
|
||||||
|
)
|
||||||
|
fi
|
||||||
|
|
||||||
|
dotnet "${ARGS[@]}"
|
||||||
|
|
||||||
|
- name: Run build-delta-from-commits (commit-analyze)
|
||||||
|
if: env.COMPARE_METHOD == 'commit-analyze'
|
||||||
|
shell: bash
|
||||||
|
run: |
|
||||||
|
set -euo pipefail
|
||||||
|
mkdir -p plonds-output
|
||||||
|
|
||||||
|
ARGS=(
|
||||||
|
'run' '--project' 'PenguinLogisticsOnlineNetworkDistributionSystem/src/Plonds.Tool/Plonds.Tool.csproj'
|
||||||
|
'--configuration' 'Release' '--'
|
||||||
|
'build-delta-from-commits'
|
||||||
|
'--platform' 'windows-x64'
|
||||||
|
'--current-version' "$RELEASE_VERSION"
|
||||||
|
'--current-zip' "$PWD/plonds-input/current-files-windows-x64.zip"
|
||||||
|
'--output-dir' "$PWD/plonds-output"
|
||||||
|
'--channel' "$RELEASE_CHANNEL"
|
||||||
|
'--baseline-tag' "${BASELINE_TAG:-$RELEASE_TAG}"
|
||||||
|
'--current-tag' "$RELEASE_TAG"
|
||||||
|
'--hash-algorithm' "$HASH_ALGORITHM"
|
||||||
|
)
|
||||||
|
|
||||||
|
if [[ -n "$BASELINE_TAG" ]]; then
|
||||||
|
ARGS+=(
|
||||||
|
'--fallback-zip' "$PWD/plonds-input/baseline-files-windows-x64.zip"
|
||||||
|
)
|
||||||
|
fi
|
||||||
|
|
||||||
|
dotnet "${ARGS[@]}"
|
||||||
|
|
||||||
|
- name: Validate output
|
||||||
|
shell: bash
|
||||||
|
run: |
|
||||||
|
set -euo pipefail
|
||||||
|
if [[ ! -f plonds-output/changed.zip ]]; then
|
||||||
|
echo "Missing output: changed.zip"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
if [[ ! -f plonds-output/PLONDS.json ]]; then
|
||||||
|
echo "Missing output: PLONDS.json"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
jq -e . plonds-output/PLONDS.json >/dev/null
|
||||||
|
|
||||||
|
- name: Upload to GitHub Release
|
||||||
|
env:
|
||||||
|
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||||
|
shell: bash
|
||||||
|
run: |
|
||||||
|
set -euo pipefail
|
||||||
|
gh release upload "$RELEASE_TAG" plonds-output/changed.zip plonds-output/PLONDS.json --clobber
|
||||||
|
|
||||||
|
- name: Persist run metadata
|
||||||
|
shell: bash
|
||||||
|
run: |
|
||||||
|
mkdir -p plonds-run-metadata
|
||||||
|
printf '%s' "$RELEASE_TAG" > plonds-run-metadata/tag.txt
|
||||||
|
printf '%s' "$COMPARE_METHOD" > plonds-run-metadata/compare-method.txt
|
||||||
|
|
||||||
|
- name: Upload run metadata artifact
|
||||||
|
uses: actions/upload-artifact@v4
|
||||||
|
with:
|
||||||
|
name: plonds-run-metadata
|
||||||
|
path: |
|
||||||
|
plonds-run-metadata/tag.txt
|
||||||
|
plonds-run-metadata/compare-method.txt
|
||||||
|
if-no-files-found: error
|
||||||
|
retention-days: 7
|
||||||
138
.github/workflows/plonds-uploader.yml
vendored
Normal file
138
.github/workflows/plonds-uploader.yml
vendored
Normal file
@@ -0,0 +1,138 @@
|
|||||||
|
name: PLONDS Publisher
|
||||||
|
|
||||||
|
concurrency:
|
||||||
|
group: plonds-publish-${{ github.event_name }}-${{ github.event.workflow_run.id || github.event.inputs.tag || github.run_id }}
|
||||||
|
cancel-in-progress: false
|
||||||
|
|
||||||
|
on:
|
||||||
|
workflow_run:
|
||||||
|
workflows:
|
||||||
|
- PLONDS Comparator
|
||||||
|
types:
|
||||||
|
- completed
|
||||||
|
workflow_dispatch:
|
||||||
|
inputs:
|
||||||
|
tag:
|
||||||
|
description: 'Release tag'
|
||||||
|
required: true
|
||||||
|
type: string
|
||||||
|
|
||||||
|
env:
|
||||||
|
DOTNET_VERSION: '10.0.x'
|
||||||
|
PLONDS_S3_PREFIX: lanmountain/update/plonds
|
||||||
|
PLONDS_S3_PUBLIC_BASE_KEY_PREFIX: lanmountain/update
|
||||||
|
PLONDS_S3_DIRECTORY_UPLOAD_CONCURRENCY: '4'
|
||||||
|
PLONDS_S3_MULTIPART_THRESHOLD_MB: '10'
|
||||||
|
PLONDS_S3_MULTIPART_PART_SIZE_MB: '10'
|
||||||
|
PLONDS_S3_MULTIPART_CONCURRENCY: '4'
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
publish:
|
||||||
|
if: ${{ github.event_name != 'workflow_run' || github.event.workflow_run.conclusion == 'success' }}
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
timeout-minutes: 360
|
||||||
|
permissions:
|
||||||
|
contents: write
|
||||||
|
actions: read
|
||||||
|
|
||||||
|
steps:
|
||||||
|
- name: Checkout
|
||||||
|
uses: actions/checkout@v4
|
||||||
|
with:
|
||||||
|
fetch-depth: 0
|
||||||
|
submodules: recursive
|
||||||
|
|
||||||
|
- name: Resolve release tag
|
||||||
|
env:
|
||||||
|
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||||
|
shell: bash
|
||||||
|
run: |
|
||||||
|
set -euo pipefail
|
||||||
|
if [[ "${{ github.event_name }}" == "workflow_dispatch" ]]; then
|
||||||
|
RAW_TAG="${{ github.event.inputs.tag }}"
|
||||||
|
if [[ "$RAW_TAG" == v* ]]; then
|
||||||
|
TAG="$RAW_TAG"
|
||||||
|
else
|
||||||
|
TAG="v$RAW_TAG"
|
||||||
|
fi
|
||||||
|
else
|
||||||
|
gh run download "${{ github.event.workflow_run.id }}" -n plonds-run-metadata -D plonds-run-metadata
|
||||||
|
TAG="$(tr -d '\r\n' < plonds-run-metadata/tag.txt)"
|
||||||
|
fi
|
||||||
|
|
||||||
|
gh release view "$TAG" --repo "${{ github.repository }}" --json tagName >/dev/null
|
||||||
|
echo "RELEASE_TAG=${TAG}" >> "$GITHUB_ENV"
|
||||||
|
|
||||||
|
- name: Setup .NET
|
||||||
|
uses: actions/setup-dotnet@v4
|
||||||
|
with:
|
||||||
|
dotnet-version: ${{ env.DOTNET_VERSION }}
|
||||||
|
dotnet-quality: preview
|
||||||
|
|
||||||
|
- name: Build PLONDS tool
|
||||||
|
run: dotnet build PenguinLogisticsOnlineNetworkDistributionSystem/src/Plonds.Tool/Plonds.Tool.csproj -c Release
|
||||||
|
|
||||||
|
- name: Download PLONDS release assets
|
||||||
|
env:
|
||||||
|
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||||
|
shell: bash
|
||||||
|
run: |
|
||||||
|
set -euo pipefail
|
||||||
|
rm -rf plonds-assets
|
||||||
|
mkdir -p plonds-assets
|
||||||
|
gh release download "$RELEASE_TAG" -p changed.zip -p PLONDS.json -p files-windows-x64.zip -D plonds-assets --clobber
|
||||||
|
test -f plonds-assets/changed.zip
|
||||||
|
test -f plonds-assets/PLONDS.json
|
||||||
|
test -f plonds-assets/files-windows-x64.zip
|
||||||
|
jq -e . plonds-assets/PLONDS.json >/dev/null
|
||||||
|
|
||||||
|
- name: Publish PLONDS assets to Rainyun S3
|
||||||
|
env:
|
||||||
|
S3_ACCESS_KEY: ${{ secrets.S3_ACCESS_KEY }}
|
||||||
|
S3_SECRET_KEY: ${{ secrets.S3_SECRET_KEY }}
|
||||||
|
S3_ENDPOINT: ${{ vars.S3_ENDPOINT }}
|
||||||
|
S3_REGION: ${{ vars.S3_REGION }}
|
||||||
|
S3_BUCKET: ${{ vars.S3_BUCKET }}
|
||||||
|
S3_PUBLIC_BASE_URL: ${{ vars.S3_PUBLIC_BASE_URL }}
|
||||||
|
shell: bash
|
||||||
|
run: |
|
||||||
|
set -euo pipefail
|
||||||
|
if [[ -z "${S3_ACCESS_KEY:-}" || -z "${S3_SECRET_KEY:-}" || -z "${S3_ENDPOINT:-}" || -z "${S3_BUCKET:-}" ]]; then
|
||||||
|
echo "S3_ACCESS_KEY, S3_SECRET_KEY, S3_ENDPOINT, and S3_BUCKET must be configured."
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
REGION="${S3_REGION:-us-east-1}"
|
||||||
|
PUBLIC_BASE="${S3_PUBLIC_BASE_URL:-https://cn-nb1.rains3.com/lmdesktop}"
|
||||||
|
PUBLIC_BASE="${PUBLIC_BASE%/}"
|
||||||
|
|
||||||
|
dotnet run --project PenguinLogisticsOnlineNetworkDistributionSystem/src/Plonds.Tool/Plonds.Tool.csproj --configuration Release -- \
|
||||||
|
publish-s3 \
|
||||||
|
--release-tag "$RELEASE_TAG" \
|
||||||
|
--repository "${{ github.repository }}" \
|
||||||
|
--manifest "$PWD/plonds-assets/PLONDS.json" \
|
||||||
|
--changed-zip "$PWD/plonds-assets/changed.zip" \
|
||||||
|
--files-zip "$PWD/plonds-assets/files-windows-x64.zip" \
|
||||||
|
--work-dir "$PWD/plonds-publish-work" \
|
||||||
|
--s3-prefix "$PLONDS_S3_PREFIX" \
|
||||||
|
--s3-endpoint "$S3_ENDPOINT" \
|
||||||
|
--s3-region "$REGION" \
|
||||||
|
--s3-bucket "$S3_BUCKET" \
|
||||||
|
--s3-access-key "$S3_ACCESS_KEY" \
|
||||||
|
--s3-secret-key "$S3_SECRET_KEY" \
|
||||||
|
--s3-public-base-url "$PUBLIC_BASE" \
|
||||||
|
--s3-public-base-key-prefix "$PLONDS_S3_PUBLIC_BASE_KEY_PREFIX" \
|
||||||
|
--directory-upload-concurrency "$PLONDS_S3_DIRECTORY_UPLOAD_CONCURRENCY" \
|
||||||
|
--multipart-threshold-mb "$PLONDS_S3_MULTIPART_THRESHOLD_MB" \
|
||||||
|
--multipart-part-size-mb "$PLONDS_S3_MULTIPART_PART_SIZE_MB" \
|
||||||
|
--multipart-concurrency "$PLONDS_S3_MULTIPART_CONCURRENCY"
|
||||||
|
|
||||||
|
jq -e '.downloads.github.changedZipUrl and .downloads.github.filesZipUrl and .downloads.s3.changedFolderUrl and .downloads.s3.filesFolderUrl' plonds-assets/PLONDS.json >/dev/null
|
||||||
|
|
||||||
|
- name: Upload enriched PLONDS manifest to GitHub Release
|
||||||
|
env:
|
||||||
|
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||||
|
shell: bash
|
||||||
|
run: |
|
||||||
|
set -euo pipefail
|
||||||
|
gh release upload "$RELEASE_TAG" plonds-assets/PLONDS.json --clobber
|
||||||
122
.github/workflows/release.yml
vendored
122
.github/workflows/release.yml
vendored
@@ -185,6 +185,29 @@ jobs:
|
|||||||
-p:InformationalVersion=${{ needs.prepare.outputs.informational_version }}
|
-p:InformationalVersion=${{ needs.prepare.outputs.informational_version }}
|
||||||
shell: pwsh
|
shell: pwsh
|
||||||
|
|
||||||
|
- name: Publish AirAppRuntime
|
||||||
|
run: |
|
||||||
|
$arch = "${{ matrix.arch }}"
|
||||||
|
$publishDir = "publish/airapp-runtime-win-$arch"
|
||||||
|
|
||||||
|
dotnet publish LanMountainDesktop.AirAppRuntime/LanMountainDesktop.AirAppRuntime.csproj `
|
||||||
|
-c Release `
|
||||||
|
-o ./$publishDir `
|
||||||
|
--self-contained:false `
|
||||||
|
-r win-$arch `
|
||||||
|
-p:SelfContained=false `
|
||||||
|
-p:PublishAot=false `
|
||||||
|
-p:PublishSingleFile=false `
|
||||||
|
-p:PublishTrimmed=false `
|
||||||
|
-p:PublishReadyToRun=false `
|
||||||
|
-p:DebugType=none `
|
||||||
|
-p:DebugSymbols=false `
|
||||||
|
-p:Version=${{ needs.prepare.outputs.version }} `
|
||||||
|
-p:AssemblyVersion=${{ needs.prepare.outputs.assembly_version }} `
|
||||||
|
-p:FileVersion=${{ needs.prepare.outputs.assembly_version }} `
|
||||||
|
-p:InformationalVersion=${{ needs.prepare.outputs.informational_version }}
|
||||||
|
shell: pwsh
|
||||||
|
|
||||||
- name: Publish AirAppHost
|
- name: Publish AirAppHost
|
||||||
run: |
|
run: |
|
||||||
$arch = "${{ matrix.arch }}"
|
$arch = "${{ matrix.arch }}"
|
||||||
@@ -215,6 +238,7 @@ jobs:
|
|||||||
$arch = "${{ matrix.arch }}"
|
$arch = "${{ matrix.arch }}"
|
||||||
$publishDir = "publish/windows-$arch"
|
$publishDir = "publish/windows-$arch"
|
||||||
$launcherPublishDir = "publish/launcher-win-$arch"
|
$launcherPublishDir = "publish/launcher-win-$arch"
|
||||||
|
$runtimePublishDir = "publish/airapp-runtime-win-$arch"
|
||||||
$appDir = "app-$version"
|
$appDir = "app-$version"
|
||||||
$newStructure = "publish-launcher/windows-$arch"
|
$newStructure = "publish-launcher/windows-$arch"
|
||||||
|
|
||||||
@@ -226,10 +250,15 @@ jobs:
|
|||||||
Copy-Item -Path "$launcherPublishDir\*" -Destination $newStructure -Recurse -Force
|
Copy-Item -Path "$launcherPublishDir\*" -Destination $newStructure -Recurse -Force
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (Test-Path $runtimePublishDir) {
|
||||||
|
Copy-Item -Path "$runtimePublishDir\*" -Destination $newStructure -Recurse -Force
|
||||||
|
}
|
||||||
|
|
||||||
New-Item -ItemType File -Path (Join-Path $appPath ".current") -Force | Out-Null
|
New-Item -ItemType File -Path (Join-Path $appPath ".current") -Force | Out-Null
|
||||||
|
|
||||||
Remove-Item -Path $publishDir -Recurse -Force -ErrorAction SilentlyContinue
|
Remove-Item -Path $publishDir -Recurse -Force -ErrorAction SilentlyContinue
|
||||||
Remove-Item -Path $launcherPublishDir -Recurse -Force -ErrorAction SilentlyContinue
|
Remove-Item -Path $launcherPublishDir -Recurse -Force -ErrorAction SilentlyContinue
|
||||||
|
Remove-Item -Path $runtimePublishDir -Recurse -Force -ErrorAction SilentlyContinue
|
||||||
Move-Item -Path $newStructure -Destination $publishDir -Force
|
Move-Item -Path $newStructure -Destination $publishDir -Force
|
||||||
shell: pwsh
|
shell: pwsh
|
||||||
|
|
||||||
@@ -244,6 +273,28 @@ jobs:
|
|||||||
-AssertClean
|
-AssertClean
|
||||||
shell: pwsh
|
shell: pwsh
|
||||||
|
|
||||||
|
- name: Verify Windows app host payload
|
||||||
|
run: |
|
||||||
|
$version = "${{ needs.prepare.outputs.version }}"
|
||||||
|
$arch = "${{ matrix.arch }}"
|
||||||
|
$publishDir = "publish/windows-$arch"
|
||||||
|
$appDir = Join-Path $publishDir "app-$version"
|
||||||
|
|
||||||
|
$requiredFiles = @(
|
||||||
|
(Join-Path $publishDir "LanMountainDesktop.Launcher.exe"),
|
||||||
|
(Join-Path $publishDir "LanMountainDesktop.AirAppRuntime.exe"),
|
||||||
|
(Join-Path $appDir "LanMountainDesktop.exe"),
|
||||||
|
(Join-Path $appDir "LanMountainDesktop.AirAppHost.exe")
|
||||||
|
)
|
||||||
|
|
||||||
|
foreach ($path in $requiredFiles) {
|
||||||
|
if (-not (Test-Path -LiteralPath $path -PathType Leaf)) {
|
||||||
|
Write-Error "Required release payload file is missing: $path"
|
||||||
|
exit 1
|
||||||
|
}
|
||||||
|
}
|
||||||
|
shell: pwsh
|
||||||
|
|
||||||
- name: Install Inno Setup and 7z
|
- name: Install Inno Setup and 7z
|
||||||
run: |
|
run: |
|
||||||
choco install innosetup -y --no-progress
|
choco install innosetup -y --no-progress
|
||||||
@@ -309,7 +360,7 @@ jobs:
|
|||||||
run: |
|
run: |
|
||||||
$version = "${{ needs.prepare.outputs.version }}"
|
$version = "${{ needs.prepare.outputs.version }}"
|
||||||
$arch = "${{ matrix.arch }}"
|
$arch = "${{ matrix.arch }}"
|
||||||
$payloadRoot = Join-Path (Join-Path $PWD "publish/windows-$arch") "app-$version"
|
$payloadRoot = Join-Path $PWD "publish/windows-$arch"
|
||||||
if (-not (Test-Path $payloadRoot)) {
|
if (-not (Test-Path $payloadRoot)) {
|
||||||
Write-Error "Payload root not found: $payloadRoot"
|
Write-Error "Payload root not found: $payloadRoot"
|
||||||
exit 1
|
exit 1
|
||||||
@@ -323,7 +374,7 @@ jobs:
|
|||||||
|
|
||||||
Get-ChildItem -Path $payloadRoot -Recurse -File | ForEach-Object {
|
Get-ChildItem -Path $payloadRoot -Recurse -File | ForEach-Object {
|
||||||
$relative = [System.IO.Path]::GetRelativePath($payloadRoot, $_.FullName).Replace('\', '/')
|
$relative = [System.IO.Path]::GetRelativePath($payloadRoot, $_.FullName).Replace('\', '/')
|
||||||
if ($relative -eq '.current' -or $relative -eq '.partial' -or $relative -eq '.destroy' -or $relative.StartsWith('.current/') -or $relative.StartsWith('.partial/') -or $relative.StartsWith('.destroy/')) {
|
if ($relative -eq '.partial' -or $relative -eq '.destroy' -or $relative.StartsWith('.partial/') -or $relative.StartsWith('.destroy/')) {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -441,12 +492,32 @@ jobs:
|
|||||||
-p:FileVersion=${{ needs.prepare.outputs.assembly_version }} \
|
-p:FileVersion=${{ needs.prepare.outputs.assembly_version }} \
|
||||||
-p:InformationalVersion=${{ needs.prepare.outputs.informational_version }}
|
-p:InformationalVersion=${{ needs.prepare.outputs.informational_version }}
|
||||||
|
|
||||||
|
- name: Publish AirAppRuntime
|
||||||
|
run: |
|
||||||
|
dotnet publish LanMountainDesktop.AirAppRuntime/LanMountainDesktop.AirAppRuntime.csproj \
|
||||||
|
-c Release \
|
||||||
|
-o ./publish/airapp-runtime-linux-x64 \
|
||||||
|
--self-contained false \
|
||||||
|
-r linux-x64 \
|
||||||
|
-p:SelfContained=false \
|
||||||
|
-p:PublishAot=false \
|
||||||
|
-p:PublishSingleFile=false \
|
||||||
|
-p:PublishTrimmed=false \
|
||||||
|
-p:PublishReadyToRun=false \
|
||||||
|
-p:DebugType=none \
|
||||||
|
-p:DebugSymbols=false \
|
||||||
|
-p:Version=${{ needs.prepare.outputs.version }} \
|
||||||
|
-p:AssemblyVersion=${{ needs.prepare.outputs.assembly_version }} \
|
||||||
|
-p:FileVersion=${{ needs.prepare.outputs.assembly_version }} \
|
||||||
|
-p:InformationalVersion=${{ needs.prepare.outputs.informational_version }}
|
||||||
|
|
||||||
- name: Restructure for Launcher
|
- name: Restructure for Launcher
|
||||||
run: |
|
run: |
|
||||||
version="${{ needs.prepare.outputs.version }}"
|
version="${{ needs.prepare.outputs.version }}"
|
||||||
publishDir="publish/linux-x64"
|
publishDir="publish/linux-x64"
|
||||||
appDir="app-$version"
|
appDir="app-$version"
|
||||||
launcherDir="publish/launcher-linux-x64"
|
launcherDir="publish/launcher-linux-x64"
|
||||||
|
runtimeDir="publish/airapp-runtime-linux-x64"
|
||||||
|
|
||||||
mkdir -p "$publishDir"
|
mkdir -p "$publishDir"
|
||||||
mv "publish/linux-x64-app" "$publishDir/$appDir"
|
mv "publish/linux-x64-app" "$publishDir/$appDir"
|
||||||
@@ -456,8 +527,13 @@ jobs:
|
|||||||
chmod +x "$publishDir/LanMountainDesktop.Launcher" 2>/dev/null || true
|
chmod +x "$publishDir/LanMountainDesktop.Launcher" 2>/dev/null || true
|
||||||
fi
|
fi
|
||||||
|
|
||||||
|
if [ -d "$runtimeDir" ]; then
|
||||||
|
cp -r "$runtimeDir"/* "$publishDir/"
|
||||||
|
chmod +x "$publishDir/LanMountainDesktop.AirAppRuntime" 2>/dev/null || true
|
||||||
|
fi
|
||||||
|
|
||||||
touch "$publishDir/$appDir/.current"
|
touch "$publishDir/$appDir/.current"
|
||||||
rm -rf "$launcherDir"
|
rm -rf "$launcherDir" "$runtimeDir"
|
||||||
|
|
||||||
- name: Package as DEB
|
- name: Package as DEB
|
||||||
run: |
|
run: |
|
||||||
@@ -616,10 +692,10 @@ jobs:
|
|||||||
dotnet publish LanMountainDesktop/LanMountainDesktop.csproj \
|
dotnet publish LanMountainDesktop/LanMountainDesktop.csproj \
|
||||||
-c Release \
|
-c Release \
|
||||||
-o ./publish/macos-${{ matrix.arch }}-app \
|
-o ./publish/macos-${{ matrix.arch }}-app \
|
||||||
--self-contained \
|
--self-contained:false \
|
||||||
-r osx-${{ matrix.arch }} \
|
-r osx-${{ matrix.arch }} \
|
||||||
|
-p:SelfContained=false \
|
||||||
-p:PublishSingleFile=false \
|
-p:PublishSingleFile=false \
|
||||||
-p:SelfContained=true \
|
|
||||||
-p:DebugType=none \
|
-p:DebugType=none \
|
||||||
-p:DebugSymbols=false \
|
-p:DebugSymbols=false \
|
||||||
-p:SkipAirAppHostBuild=true \
|
-p:SkipAirAppHostBuild=true \
|
||||||
@@ -630,6 +706,36 @@ jobs:
|
|||||||
-p:FileVersion=${{ needs.prepare.outputs.assembly_version }} \
|
-p:FileVersion=${{ needs.prepare.outputs.assembly_version }} \
|
||||||
-p:InformationalVersion=${{ needs.prepare.outputs.informational_version }}
|
-p:InformationalVersion=${{ needs.prepare.outputs.informational_version }}
|
||||||
|
|
||||||
|
- name: Publish AirAppRuntime
|
||||||
|
run: |
|
||||||
|
dotnet publish LanMountainDesktop.AirAppRuntime/LanMountainDesktop.AirAppRuntime.csproj \
|
||||||
|
-c Release \
|
||||||
|
-o ./publish/airapp-runtime-macos-${{ matrix.arch }} \
|
||||||
|
--self-contained false \
|
||||||
|
-r osx-${{ matrix.arch }} \
|
||||||
|
-p:SelfContained=false \
|
||||||
|
-p:PublishAot=false \
|
||||||
|
-p:PublishSingleFile=false \
|
||||||
|
-p:PublishTrimmed=false \
|
||||||
|
-p:PublishReadyToRun=false \
|
||||||
|
-p:DebugType=none \
|
||||||
|
-p:DebugSymbols=false \
|
||||||
|
-p:Version=${{ needs.prepare.outputs.version }} \
|
||||||
|
-p:AssemblyVersion=${{ needs.prepare.outputs.assembly_version }} \
|
||||||
|
-p:FileVersion=${{ needs.prepare.outputs.assembly_version }} \
|
||||||
|
-p:InformationalVersion=${{ needs.prepare.outputs.informational_version }}
|
||||||
|
|
||||||
|
- name: Optimize and Guard macOS Payload
|
||||||
|
run: |
|
||||||
|
arch="${{ matrix.arch }}"
|
||||||
|
publishDir="publish/macos-${arch}-app"
|
||||||
|
|
||||||
|
pwsh ./LanMountainDesktop/scripts/Optimize-PublishPayload.ps1 \
|
||||||
|
-PublishDir "$publishDir" \
|
||||||
|
-RuntimeIdentifier "osx-${arch}" \
|
||||||
|
-AssertClean
|
||||||
|
shell: bash
|
||||||
|
|
||||||
- name: Package Payload Zip
|
- name: Package Payload Zip
|
||||||
run: |
|
run: |
|
||||||
release_dir="$PWD/release-assets"
|
release_dir="$PWD/release-assets"
|
||||||
@@ -652,6 +758,7 @@ jobs:
|
|||||||
app_name="LanMountainDesktop"
|
app_name="LanMountainDesktop"
|
||||||
package_name="${app_name}-${version}-macos-${arch}"
|
package_name="${app_name}-${version}-macos-${arch}"
|
||||||
launcherDir="publish/launcher-macos-$arch"
|
launcherDir="publish/launcher-macos-$arch"
|
||||||
|
runtimeDir="publish/airapp-runtime-macos-$arch"
|
||||||
appSourceDir="publish/macos-$arch-app"
|
appSourceDir="publish/macos-$arch-app"
|
||||||
|
|
||||||
mkdir -p "${app_name}.app/Contents/MacOS"
|
mkdir -p "${app_name}.app/Contents/MacOS"
|
||||||
@@ -664,6 +771,11 @@ jobs:
|
|||||||
chmod +x "${app_name}.app/Contents/MacOS/LanMountainDesktop.Launcher" 2>/dev/null || true
|
chmod +x "${app_name}.app/Contents/MacOS/LanMountainDesktop.Launcher" 2>/dev/null || true
|
||||||
fi
|
fi
|
||||||
|
|
||||||
|
if [ -d "$runtimeDir" ]; then
|
||||||
|
cp -r "$runtimeDir"/* "${app_name}.app/Contents/MacOS/"
|
||||||
|
chmod +x "${app_name}.app/Contents/MacOS/LanMountainDesktop.AirAppRuntime" 2>/dev/null || true
|
||||||
|
fi
|
||||||
|
|
||||||
touch "${app_name}.app/Contents/MacOS/$appDir/.current"
|
touch "${app_name}.app/Contents/MacOS/$appDir/.current"
|
||||||
mkdir -p "${app_name}.app/Contents/Resources"
|
mkdir -p "${app_name}.app/Contents/Resources"
|
||||||
|
|
||||||
|
|||||||
9
.trae/specs/air-app-runtime-container/checklist.md
Normal file
9
.trae/specs/air-app-runtime-container/checklist.md
Normal file
@@ -0,0 +1,9 @@
|
|||||||
|
# Checklist
|
||||||
|
|
||||||
|
- [x] `LanMountainDesktop.AirAppRuntime` is included in `LanMountainDesktop.slnx`.
|
||||||
|
- [x] Launcher no longer hosts `IAirAppLifecycleService`.
|
||||||
|
- [x] Host fallback starts `LanMountainDesktop.AirAppRuntime`, not `LanMountainDesktop.Launcher air-app-broker`.
|
||||||
|
- [x] AirApp Runtime is explicitly non-AOT and framework-dependent.
|
||||||
|
- [x] `dotnet build LanMountainDesktop.slnx -c Debug` passes.
|
||||||
|
- [x] Related AirApp Runtime tests pass.
|
||||||
|
- [x] `dotnet test LanMountainDesktop.slnx -c Debug` passes.
|
||||||
21
.trae/specs/air-app-runtime-container/spec.md
Normal file
21
.trae/specs/air-app-runtime-container/spec.md
Normal file
@@ -0,0 +1,21 @@
|
|||||||
|
# AirApp Runtime Container
|
||||||
|
|
||||||
|
## Goal
|
||||||
|
|
||||||
|
Move built-in Air APP lifecycle management out of Launcher into a dedicated framework-dependent JIT process named `LanMountainDesktop.AirAppRuntime`.
|
||||||
|
|
||||||
|
## Behavior
|
||||||
|
|
||||||
|
- Launcher remains the user-facing entry point and pre-starts AirApp Runtime during normal `launch`.
|
||||||
|
- AirApp Runtime exposes `IAirAppLifecycleService` and `IAirAppRuntimeControlService` on `LanMountainDesktop.AirAppRuntime.v1`.
|
||||||
|
- Desktop host requests Air APP operations through AirApp Runtime IPC.
|
||||||
|
- If the runtime pipe is unavailable, the desktop host starts `LanMountainDesktop.AirAppRuntime` directly and retries.
|
||||||
|
- AirApp Runtime keeps one AirAppHost process per `{appId}:{sourceComponentId}:{sourcePlacementId}` key, with `world-clock` sharing `world-clock:clock-suite:global`.
|
||||||
|
- AirApp Runtime remains alive while Launcher, Host, requester, or any AirAppHost process is alive.
|
||||||
|
- AirApp Runtime exits after Launcher/Host/requester are gone and no Air APP windows remain.
|
||||||
|
|
||||||
|
## Out of Scope
|
||||||
|
|
||||||
|
- Moving Air APP windows into the runtime process.
|
||||||
|
- Third-party plugin-declared Air APP metadata.
|
||||||
|
- Persisting the Air APP instance table across OS reboot.
|
||||||
11
.trae/specs/air-app-runtime-container/tasks.md
Normal file
11
.trae/specs/air-app-runtime-container/tasks.md
Normal file
@@ -0,0 +1,11 @@
|
|||||||
|
# Tasks
|
||||||
|
|
||||||
|
- [x] Add shared AirApp Runtime IPC/control contracts.
|
||||||
|
- [x] Add shared AirApp Runtime path resolver and process starter.
|
||||||
|
- [x] Add `LanMountainDesktop.AirAppRuntime` as a framework-dependent JIT process.
|
||||||
|
- [x] Move Air APP lifecycle service out of Launcher.
|
||||||
|
- [x] Make Launcher pre-start AirApp Runtime and attach Host PID after launch.
|
||||||
|
- [x] Make Host fallback start AirApp Runtime instead of Launcher broker.
|
||||||
|
- [x] Remove Launcher `air-app-broker` command handling.
|
||||||
|
- [x] Update packaging scripts and release workflow to include AirApp Runtime.
|
||||||
|
- [x] Update unit tests and architecture/package assertions.
|
||||||
@@ -1,5 +1,7 @@
|
|||||||
# Checklist
|
# Checklist
|
||||||
|
|
||||||
|
> Superseded by `.trae/specs/air-app-runtime-container/`; the checked items below describe the former Launcher-managed implementation.
|
||||||
|
|
||||||
- [x] `LanMountainDesktop.Shared.IPC` builds in Debug.
|
- [x] `LanMountainDesktop.Shared.IPC` builds in Debug.
|
||||||
- [x] `LanMountainDesktop.Launcher` builds in Debug.
|
- [x] `LanMountainDesktop.Launcher` builds in Debug.
|
||||||
- [x] `LanMountainDesktop` builds in Debug.
|
- [x] `LanMountainDesktop` builds in Debug.
|
||||||
|
|||||||
@@ -1,5 +1,7 @@
|
|||||||
# Launcher Managed Air APP Lifecycle
|
# Launcher Managed Air APP Lifecycle
|
||||||
|
|
||||||
|
> Superseded by `.trae/specs/air-app-runtime-container/`. Launcher no longer hosts the Air APP lifecycle broker; it pre-starts `LanMountainDesktop.AirAppRuntime`, which owns the lifecycle IPC and AirAppHost process table.
|
||||||
|
|
||||||
## Goal
|
## Goal
|
||||||
|
|
||||||
Make Launcher the authoritative lifecycle manager for built-in Air APP processes. The desktop host requests Air APP operations through IPC, while Launcher creates, activates, tracks, and cleans up Air APP host processes.
|
Make Launcher the authoritative lifecycle manager for built-in Air APP processes. The desktop host requests Air APP operations through IPC, while Launcher creates, activates, tracks, and cleans up Air APP host processes.
|
||||||
|
|||||||
@@ -1,5 +1,7 @@
|
|||||||
# Tasks
|
# Tasks
|
||||||
|
|
||||||
|
> Superseded by `.trae/specs/air-app-runtime-container/`; the checked items below describe the former Launcher-managed implementation.
|
||||||
|
|
||||||
- [x] Add shared Air APP lifecycle IPC contracts.
|
- [x] Add shared Air APP lifecycle IPC contracts.
|
||||||
- [x] Add Launcher Air APP lifecycle service and dedicated IPC host.
|
- [x] Add Launcher Air APP lifecycle service and dedicated IPC host.
|
||||||
- [x] Make Launcher remain alive while desktop or Air APP processes exist.
|
- [x] Make Launcher remain alive while desktop or Air APP processes exist.
|
||||||
|
|||||||
@@ -3,7 +3,7 @@
|
|||||||
- [ ] New install shows OOBE once.
|
- [ ] New install shows OOBE once.
|
||||||
- [ ] Same-user reinstall does not show OOBE again.
|
- [ ] Same-user reinstall does not show OOBE again.
|
||||||
- [ ] `postinstall` launch path is handled without misclassifying the user state.
|
- [ ] `postinstall` launch path is handled without misclassifying the user state.
|
||||||
- [ ] `apply-update` and `plugin-install` do not auto-enter OOBE.
|
- [ ] `plugin-install` does not auto-enter OOBE.
|
||||||
- [ ] Default plugin install does not request UAC.
|
- [ ] Default plugin install does not request UAC.
|
||||||
- [ ] Logs include OOBE status, suppression reason, and launch source.
|
- [ ] Logs include OOBE status, suppression reason, and launch source.
|
||||||
- [ ] Startup presentation step inside `OobeWindow` (after data location) writes host `settings.json` and syncs Windows Run when autostart is chosen (Launcher executable).
|
- [ ] Startup presentation step inside `OobeWindow` (after data location) writes host `settings.json` and syncs Windows Run when autostart is chosen (Launcher executable).
|
||||||
|
|||||||
@@ -23,12 +23,11 @@ Stabilize the launcher startup path so that:
|
|||||||
- `launchSource` values are treated as:
|
- `launchSource` values are treated as:
|
||||||
- `normal`
|
- `normal`
|
||||||
- `postinstall`
|
- `postinstall`
|
||||||
- `apply-update`
|
|
||||||
- `plugin-install`
|
- `plugin-install`
|
||||||
- `debug-preview`
|
- `debug-preview`
|
||||||
- Automatic OOBE is allowed only for normal user-mode startup.
|
- Automatic OOBE is allowed only for normal user-mode startup.
|
||||||
- `postinstall` may show OOBE only when the launcher is not elevated and user state is available.
|
- `postinstall` may show OOBE only when the launcher is not elevated and user state is available.
|
||||||
- `apply-update`, `plugin-install`, and `debug-preview` must not auto-enter OOBE.
|
- `plugin-install` and `debug-preview` must not auto-enter OOBE.
|
||||||
- Allowed elevation paths are limited to:
|
- Allowed elevation paths are limited to:
|
||||||
- the installer itself
|
- the installer itself
|
||||||
- full installer update application
|
- full installer update application
|
||||||
|
|||||||
@@ -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`.
|
|
||||||
@@ -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`
|
|
||||||
@@ -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.
|
|
||||||
174
.trae/specs/plonds-client-service/spec.md
Normal file
174
.trae/specs/plonds-client-service/spec.md
Normal file
@@ -0,0 +1,174 @@
|
|||||||
|
# PLONDS Client Service 独立化设计
|
||||||
|
|
||||||
|
> 日期:2026-06-01
|
||||||
|
> 状态:设计中
|
||||||
|
|
||||||
|
## 1. 目标
|
||||||
|
|
||||||
|
PLONDS 在应用内必须作为独立服务存在,负责分发发现、下载、校验和本地包准备。它不是现有 Update 模块的 provider,也不应把 S3/GitHub/source 选择逻辑混入 `LanMountainDesktop/Services/Update/`。
|
||||||
|
|
||||||
|
最终边界:
|
||||||
|
|
||||||
|
- PLONDS 服务:寻找最新版本、选择下载源、下载 manifest 和包、校验文件、准备本地 staging。
|
||||||
|
- 安装程序/安装网关:只消费 PLONDS 已准备好的本地安装输入,执行增量安装或完整安装。
|
||||||
|
- UI:只展示 PLONDS 服务和安装程序返回的状态;完整包也失败后才处理错误。
|
||||||
|
|
||||||
|
## 2. 当前耦合点
|
||||||
|
|
||||||
|
当前需要拆离的耦合点:
|
||||||
|
|
||||||
|
- `LanMountainDesktop/Services/Settings/SettingsDomainServices.cs`
|
||||||
|
- 直接持有 `PlondsStaticUpdateService` 与 `PlondsReleaseUpdateService`
|
||||||
|
- 在 `CheckForUpdatesCoreAsync` 中把 PLONDS 和 GitHub Update fallback 逻辑混在一起
|
||||||
|
- `LanMountainDesktop/Services/Update/UpdateInstallGateway.cs`
|
||||||
|
- 直接判断 `UpdatePayloadKind.DeltaPlonds`
|
||||||
|
- 直接实例化 `PlondsUpdateApplier`
|
||||||
|
- `LanMountainDesktop/Services/Update/Plonds*.cs`
|
||||||
|
- PLONDS apply/parser/payload resolver 仍位于 Update 命名空间
|
||||||
|
|
||||||
|
## 3. Source 发现规则
|
||||||
|
|
||||||
|
PLONDS 客户端内置两个初始地址:
|
||||||
|
|
||||||
|
1. S3 上的 PLONDS manifest 地址
|
||||||
|
2. GitHub Release 上的 PLONDS manifest 地址
|
||||||
|
|
||||||
|
两个地址读取的是同一种 JSON 文件,当前文件名为 `PLONDS.json`。客户端每次检查增量更新时,会并行或顺序请求所有已知 source 的 `PLONDS.json`。
|
||||||
|
|
||||||
|
### 3.1 Source 扩展
|
||||||
|
|
||||||
|
`PLONDS.json` 可以声明额外 source。客户端读取到额外 source 后,应把它们加入下一轮寻找列表。
|
||||||
|
|
||||||
|
建议 manifest 扩展字段:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"sources": [
|
||||||
|
{
|
||||||
|
"id": "rainyun-s3",
|
||||||
|
"kind": "s3",
|
||||||
|
"manifestUrl": "https://example.com/plonds/1.2.3/PLONDS.json",
|
||||||
|
"priority": 100
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "github",
|
||||||
|
"kind": "github",
|
||||||
|
"manifestUrl": "https://github.com/owner/repo/releases/download/v1.2.3/PLONDS.json",
|
||||||
|
"priority": 50
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
规则:
|
||||||
|
|
||||||
|
- `sources` 为空或缺失时,只使用内置 S3 + GitHub。
|
||||||
|
- 新 source 不覆盖内置 source,除非 `id` 相同。
|
||||||
|
- source 列表需要去重,按 `id` 和 `manifestUrl` 双重去重。
|
||||||
|
- source 持久化到 PLONDS 自己的配置/缓存,不写入 Update 设置。
|
||||||
|
|
||||||
|
## 4. 版本选择规则
|
||||||
|
|
||||||
|
如果多个 source 返回的版本不一致,客户端选择 `currentVersion` 最高的 manifest。
|
||||||
|
|
||||||
|
规则:
|
||||||
|
|
||||||
|
- 版本解析使用 `Version` 语义,忽略前导 `v`。
|
||||||
|
- 版本相同时,优先选择下载可用性更高的 source。
|
||||||
|
- 如果最高版本 manifest 下载包失败,可以尝试同版本的其他 source。
|
||||||
|
- 不因为低版本 source 成功而降级,除非用户显式允许。
|
||||||
|
|
||||||
|
## 5. 下载与回退规则
|
||||||
|
|
||||||
|
PLONDS 服务优先走增量包:
|
||||||
|
|
||||||
|
1. 下载所选 manifest。
|
||||||
|
2. 下载 `changed.zip`。
|
||||||
|
3. 校验 `changed.zip` 与 manifest 中的 hash/checksum。
|
||||||
|
4. 解压或准备增量 staging。
|
||||||
|
5. 交给安装程序执行增量安装。
|
||||||
|
|
||||||
|
如果增量流程失败,PLONDS 服务自动改用完整包:
|
||||||
|
|
||||||
|
1. 下载 `Files.zip`。
|
||||||
|
2. 校验 `Files.zip`。
|
||||||
|
3. 解压或准备完整包 staging。
|
||||||
|
4. 交给安装程序执行完整包安装。
|
||||||
|
|
||||||
|
如果完整包也失败,PLONDS 服务返回失败结果,由 UI 展示错误和重试入口。
|
||||||
|
|
||||||
|
## 6. 发布产物布局
|
||||||
|
|
||||||
|
Publisher 上传到 S3 的版本目录:
|
||||||
|
|
||||||
|
```text
|
||||||
|
<prefix>/<version>/PLONDS.json
|
||||||
|
<prefix>/<version>/changed.zip
|
||||||
|
<prefix>/<version>/<version>-changed/**
|
||||||
|
<prefix>/<version>/Files.zip
|
||||||
|
<prefix>/<version>/<version>-Files/**
|
||||||
|
```
|
||||||
|
|
||||||
|
说明:
|
||||||
|
|
||||||
|
- `Files.zip` 是上传到 S3 时的完整包标准名。
|
||||||
|
- `<version>-Files/` 是 S3 上解压后的完整包目录。
|
||||||
|
- `<prefix>/PLONDS.json` 是 S3 的固定 latest manifest 地址,和 GitHub Release latest manifest 一起作为客户端内置初始 source。
|
||||||
|
- GitHub Release 仍可保留平台原始文件名,例如 `files-windows-x64.zip`。
|
||||||
|
- `PLONDS.json` 的 downloads 字段同时包含 GitHub 与 S3 的增量包、完整包位置。
|
||||||
|
- Publisher 必须先完成版本目录内的 `changed.zip`、`Files.zip`、解压目录和版本 `PLONDS.json` 上传,再更新 `<prefix>/PLONDS.json` latest 指针。
|
||||||
|
- Publisher 的 S3 目录上传必须支持重跑续传;同 key 且大小一致的对象可以跳过,避免失败后从头上传完整包目录。
|
||||||
|
- Publisher 上传大对象时应使用 S3 multipart upload,以避免 `changed.zip` / `Files.zip` 在低吞吐链路上被单次 PUT 长时间阻塞。
|
||||||
|
|
||||||
|
## 7. 建议代码结构
|
||||||
|
|
||||||
|
```text
|
||||||
|
LanMountainDesktop/Services/Plonds/
|
||||||
|
IPlondsService.cs
|
||||||
|
PlondsService.cs
|
||||||
|
Sources/
|
||||||
|
IPlondsSource.cs
|
||||||
|
PlondsHttpManifestSource.cs
|
||||||
|
PlondsSourceRegistry.cs
|
||||||
|
Download/
|
||||||
|
PlondsDownloader.cs
|
||||||
|
PlondsDownloadPlanner.cs
|
||||||
|
Verification/
|
||||||
|
PlondsVerifier.cs
|
||||||
|
Staging/
|
||||||
|
PlondsPackageStore.cs
|
||||||
|
PlondsPreparedPackage.cs
|
||||||
|
Models/
|
||||||
|
PlondsClientManifest.cs
|
||||||
|
PlondsSourceDescriptor.cs
|
||||||
|
PlondsCheckResult.cs
|
||||||
|
```
|
||||||
|
|
||||||
|
后续如果要移植,优先把这棵目录或等价项目抽成独立库。
|
||||||
|
|
||||||
|
## 8. 与安装程序的交接契约
|
||||||
|
|
||||||
|
PLONDS 服务输出本地 prepared package:
|
||||||
|
|
||||||
|
```csharp
|
||||||
|
public sealed record PlondsPreparedPackage(
|
||||||
|
Version Version,
|
||||||
|
PlondsPackageMode Mode,
|
||||||
|
string ManifestPath,
|
||||||
|
string? ChangedZipPath,
|
||||||
|
string? ChangedDirectory,
|
||||||
|
string? FilesZipPath,
|
||||||
|
string? FilesDirectory);
|
||||||
|
```
|
||||||
|
|
||||||
|
安装程序只接受这个结果,不参与 source 发现、下载和校验。
|
||||||
|
|
||||||
|
## 9. 实施顺序
|
||||||
|
|
||||||
|
1. Publisher 补齐完整包 S3 上传与 manifest downloads 字段。
|
||||||
|
2. 新增 `Services/Plonds/` 客户端服务骨架和模型。
|
||||||
|
3. 把 `PlondsStaticUpdateService` / `PlondsReleaseUpdateService` 合并迁移到独立 PLONDS source 体系。
|
||||||
|
4. 把 `LanMountainDesktop/Services/Update/Plonds*.cs` 迁出 Update 命名空间。
|
||||||
|
5. `UpdateSettingsService` 改为调用 `IPlondsService`,不再直接组合 S3/GitHub PLONDS fallback。
|
||||||
|
6. 安装入口只接收 `PlondsPreparedPackage`。
|
||||||
|
7. 添加单元测试覆盖 source 扩展、最高版本选择、增量失败转完整包、完整包失败交 UI。
|
||||||
549
.trae/specs/plonds-comparator-redesign/spec.md
Normal file
549
.trae/specs/plonds-comparator-redesign/spec.md
Normal file
@@ -0,0 +1,549 @@
|
|||||||
|
# PLONDS Comparator 改造设计
|
||||||
|
|
||||||
|
> 日期:2026-05-30
|
||||||
|
> 状态:待审批
|
||||||
|
|
||||||
|
## 1. 背景与动机
|
||||||
|
|
||||||
|
PLONDS(Penguin Logistics Online Network Distribution System)是 LanMountainDesktop 的文件驱动式分布式更新系统。当前 Comparator 工作流存在以下问题:
|
||||||
|
|
||||||
|
1. **产出物过于复杂**:生成 `update-{platform}.zip`、`plonds-filemap-{platform}.json`、`plonds-filemap-{platform}.json.sig`、`platform-summary-{platform}.json`、`plonds-static.zip` 等多个文件,客户端消费困难
|
||||||
|
2. **模型定义重复**:`Plonds.Shared`、`Plonds.Core`、宿主侧、Launcher 侧各自定义独立的 DTO,字段名不一致
|
||||||
|
3. **签名机制过重**:RSA 签名增加了 CI 复杂度(需要管理密钥),且对文件驱动式更新系统而言 SHA256 哈希校验已足够
|
||||||
|
4. **平台覆盖不当**:Linux 平台不需要 PLONDS 支持,macOS 尚未接入,但代码中硬编码了三个平台
|
||||||
|
5. **工作流间 artifact 传递脆弱**:Comparator → Publisher 通过 artifact 传递 `plonds-static.zip`,容易断裂
|
||||||
|
|
||||||
|
## 2. 设计目标
|
||||||
|
|
||||||
|
- 产出物精简为两个文件:`changed.zip` + `PLONDS.json`
|
||||||
|
- 去掉 RSA 签名,只用 SHA256/MD5 校验
|
||||||
|
- 只关注 Windows 平台
|
||||||
|
- 统一模型定义,消除 DTO 重复
|
||||||
|
- 保持 Comparator 和 Publisher 两个工作流的职责分离
|
||||||
|
|
||||||
|
## 3. 新产出物定义
|
||||||
|
|
||||||
|
### 3.1 changed.zip
|
||||||
|
|
||||||
|
只包含与上一版本有差异的文件(action 为 `add` 或 `replace` 的文件),目录结构与部署目录一致。
|
||||||
|
|
||||||
|
### 3.2 PLONDS.json
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"formatVersion": "2.0",
|
||||||
|
"currentVersion": "1.2.0",
|
||||||
|
"previousVersion": "1.1.0",
|
||||||
|
"isFullUpdate": false,
|
||||||
|
"requiresCleanInstall": false,
|
||||||
|
"channel": "stable",
|
||||||
|
"platform": "windows-x64",
|
||||||
|
"updatedAt": "2026-05-30T12:00:00Z",
|
||||||
|
|
||||||
|
"filesMap": {
|
||||||
|
"LanMountainDesktop.exe": {
|
||||||
|
"action": "replace",
|
||||||
|
"sha256": "abc123...",
|
||||||
|
"size": 1024000
|
||||||
|
},
|
||||||
|
"LanMountainDesktop.dll": {
|
||||||
|
"action": "reuse",
|
||||||
|
"sha256": "def456...",
|
||||||
|
"size": 512000
|
||||||
|
},
|
||||||
|
"OldModule.dll": {
|
||||||
|
"action": "delete",
|
||||||
|
"sha256": "",
|
||||||
|
"size": 0
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
"changedFilesMap": {
|
||||||
|
"LanMountainDesktop.exe": {
|
||||||
|
"archivePath": "LanMountainDesktop.exe",
|
||||||
|
"sha256": "abc123...",
|
||||||
|
"size": 1024000
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
"checksums": {
|
||||||
|
"changed.zip": "md5:9a8b7c6d..."
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### 3.3 字段语义
|
||||||
|
|
||||||
|
| 字段 | 类型 | 说明 |
|
||||||
|
|------|------|------|
|
||||||
|
| `formatVersion` | string | 协议版本,固定 `"2.0"` |
|
||||||
|
| `currentVersion` | string | 当前发布版本 |
|
||||||
|
| `previousVersion` | string | 基线版本(全量更新时为 `"0.0.0"`) |
|
||||||
|
| `isFullUpdate` | bool | 是否为全量更新(找不到基线版本时为 true) |
|
||||||
|
| `requiresCleanInstall` | bool | 启动器是否也更新了——如果是,客户端不走增量流程,让用户重新运行安装器 |
|
||||||
|
| `channel` | string | 更新通道:`stable` 或 `preview` |
|
||||||
|
| `platform` | string | 平台标识:`windows-x64` |
|
||||||
|
| `updatedAt` | string | ISO 8601 时间戳 |
|
||||||
|
| `filesMap` | object | 全量文件图:每个文件的 action + sha256 + size |
|
||||||
|
| `changedFilesMap` | object | 变更文件图:只包含需要从 changed.zip 解压的文件 |
|
||||||
|
| `checksums` | object | 产出物的 MD5 值 |
|
||||||
|
|
||||||
|
### 3.4 filesMap 中 action 的值
|
||||||
|
|
||||||
|
| Action | 含义 | changed.zip 中是否包含 |
|
||||||
|
|--------|------|----------------------|
|
||||||
|
| `add` | 新增文件 | ✅ |
|
||||||
|
| `replace` | 替换文件 | ✅ |
|
||||||
|
| `reuse` | 复用上一版本文件 | ❌ |
|
||||||
|
| `delete` | 删除文件 | ❌ |
|
||||||
|
|
||||||
|
### 3.5 requiresCleanInstall 判断逻辑
|
||||||
|
|
||||||
|
比较 `LanMountainDesktop.Launcher.exe` 在当前版本和基线版本中的 SHA256:
|
||||||
|
- 如果 SHA256 不同 → `requiresCleanInstall = true`
|
||||||
|
- 如果 SHA256 相同或没有基线版本 → `requiresCleanInstall = false`
|
||||||
|
|
||||||
|
## 4. Plonds.Tool build-delta 命令改造
|
||||||
|
|
||||||
|
### 4.1 新命令签名
|
||||||
|
|
||||||
|
```
|
||||||
|
build-delta --platform <platform>
|
||||||
|
--current-version <version>
|
||||||
|
--current-zip <file>
|
||||||
|
--output-dir <dir>
|
||||||
|
--channel <channel>
|
||||||
|
[--baseline-version <version>]
|
||||||
|
[--baseline-zip <file>]
|
||||||
|
[--launcher-path <relative-path>]
|
||||||
|
```
|
||||||
|
|
||||||
|
### 4.2 参数说明
|
||||||
|
|
||||||
|
| 参数 | 必需 | 说明 |
|
||||||
|
|------|------|------|
|
||||||
|
| `--platform` | 是 | 平台标识,如 `windows-x64` |
|
||||||
|
| `--current-version` | 是 | 当前发布版本号 |
|
||||||
|
| `--current-zip` | 是 | 当前版本的 payload zip 路径 |
|
||||||
|
| `--output-dir` | 是 | 输出目录 |
|
||||||
|
| `--channel` | 是 | 更新通道 |
|
||||||
|
| `--baseline-version` | 否 | 基线版本号(省略则视为全量更新) |
|
||||||
|
| `--baseline-zip` | 否 | 基线版本的 payload zip 路径(省略则视为全量更新) |
|
||||||
|
| `--launcher-path` | 否 | Launcher 可执行文件的相对路径,默认 `LanMountainDesktop.Launcher.exe` |
|
||||||
|
|
||||||
|
### 4.3 删除的参数
|
||||||
|
|
||||||
|
| 参数 | 原因 |
|
||||||
|
|------|------|
|
||||||
|
| `--current-tag` | 不再需要,version 就够了 |
|
||||||
|
| `--private-key` | 去掉签名 |
|
||||||
|
| `--is-full-payload` | 自动判断:没有 baseline-zip 就是全量 |
|
||||||
|
| `--static-output-dir` | 不再生成 S3 静态布局 |
|
||||||
|
| `--update-base-url` | 不再生成 S3 URL |
|
||||||
|
| `--baseline-tag` | 不再需要 |
|
||||||
|
|
||||||
|
### 4.4 内部逻辑
|
||||||
|
|
||||||
|
```
|
||||||
|
1. 解压 current-zip → currentDir
|
||||||
|
2. 如果有 baseline-zip → 解压 → baselineDir
|
||||||
|
否则 → baselineDir = 空(全量更新)
|
||||||
|
|
||||||
|
3. 扫描 currentDir → 计算 SHA256
|
||||||
|
4. 扫描 baselineDir → 计算 SHA256(如果有)
|
||||||
|
|
||||||
|
5. 对比生成 filesMap:
|
||||||
|
- 两个版本都有且 SHA256 相同 → reuse
|
||||||
|
- 两个版本都有但 SHA256 不同 → replace
|
||||||
|
- 只在新版本中存在 → add
|
||||||
|
- 只在旧版本中存在 → delete
|
||||||
|
|
||||||
|
6. 从 filesMap 提取 changedFilesMap:
|
||||||
|
- 只包含 action=add/replace 的条目
|
||||||
|
- 添加 archivePath(在 changed.zip 中的路径)
|
||||||
|
|
||||||
|
7. 打包 changed.zip:
|
||||||
|
- 只包含 add/replace 的文件
|
||||||
|
- 保持原始目录结构
|
||||||
|
|
||||||
|
8. 判断 requiresCleanInstall:
|
||||||
|
- 比较 Launcher 可执行文件在两个版本中的 SHA256
|
||||||
|
- 如果不同 → requiresCleanInstall=true
|
||||||
|
|
||||||
|
9. 计算 changed.zip 的 MD5
|
||||||
|
|
||||||
|
10. 生成 PLONDS.json
|
||||||
|
|
||||||
|
11. 输出到 output-dir:
|
||||||
|
- changed.zip
|
||||||
|
- PLONDS.json
|
||||||
|
```
|
||||||
|
|
||||||
|
### 4.5 不再生成的产物
|
||||||
|
|
||||||
|
| 旧产物 | 处置 |
|
||||||
|
|--------|------|
|
||||||
|
| `update-{platform}.zip` | 被 `changed.zip` 替代 |
|
||||||
|
| `plonds-filemap-{platform}.json` | 被 `PLONDS.json` 替代 |
|
||||||
|
| `plonds-filemap-{platform}.json.sig` | 去掉签名 |
|
||||||
|
| `platform-summary-{platform}.json` | 不再需要 |
|
||||||
|
| `plonds-static.zip` | 不再生成 S3 静态布局 |
|
||||||
|
| `meta/channels/...` | 不再由 Tool 生成,由 Publisher 负责 |
|
||||||
|
|
||||||
|
## 5. Plonds.Shared 模型改造
|
||||||
|
|
||||||
|
### 5.1 删除的模型
|
||||||
|
|
||||||
|
| 模型 | 原因 |
|
||||||
|
|------|------|
|
||||||
|
| `PlondsFileMap` | 被新的 `PlondsManifest` 替代 |
|
||||||
|
| `PlondsFileEntry` | 被新的 `PlondsFileEntry` 替代 |
|
||||||
|
| `PlondsComponent` | 不再有组件概念 |
|
||||||
|
| `PlondsDistributionInfo` | 不再生成分发文档 |
|
||||||
|
| `PlondsChannelPointer` | 由 Publisher 用脚本生成 |
|
||||||
|
| `PlondsReleaseManifest` | 不再需要 |
|
||||||
|
| `PlondsReleasePlatformEntry` | 不再需要 |
|
||||||
|
| `PlondsSignatureDescriptor` | 去掉签名 |
|
||||||
|
| `PlondsMirrorAsset` | 由 Publisher 处理 |
|
||||||
|
| `PlondsMirrorEntry` | 由 Publisher 处理 |
|
||||||
|
| `PlondsMetadataCatalog` | 不再需要 |
|
||||||
|
| `PlondsAssetEntry` | 不再需要 |
|
||||||
|
|
||||||
|
### 5.2 新模型定义
|
||||||
|
|
||||||
|
```csharp
|
||||||
|
// PlondsManifest — 对应 PLONDS.json
|
||||||
|
public sealed record PlondsManifest(
|
||||||
|
string FormatVersion,
|
||||||
|
string CurrentVersion,
|
||||||
|
string PreviousVersion,
|
||||||
|
bool IsFullUpdate,
|
||||||
|
bool RequiresCleanInstall,
|
||||||
|
string Channel,
|
||||||
|
string Platform,
|
||||||
|
DateTimeOffset UpdatedAt,
|
||||||
|
IReadOnlyDictionary<string, PlondsFileEntry> FilesMap,
|
||||||
|
IReadOnlyDictionary<string, PlondsChangedFileEntry> ChangedFilesMap,
|
||||||
|
IReadOnlyDictionary<string, string> Checksums);
|
||||||
|
|
||||||
|
// PlondsFileEntry — filesMap 中的条目
|
||||||
|
public sealed record PlondsFileEntry(
|
||||||
|
string Action, // add | replace | reuse | delete
|
||||||
|
string Sha256,
|
||||||
|
long Size);
|
||||||
|
|
||||||
|
// PlondsChangedFileEntry — changedFilesMap 中的条目
|
||||||
|
public sealed record PlondsChangedFileEntry(
|
||||||
|
string ArchivePath, // 在 changed.zip 中的路径
|
||||||
|
string Sha256,
|
||||||
|
long Size);
|
||||||
|
```
|
||||||
|
|
||||||
|
### 5.3 设计决策
|
||||||
|
|
||||||
|
- `FilesMap` 和 `ChangedFilesMap` 用 `IReadOnlyDictionary<string, T>` 而非 `IReadOnlyList<T>`,key 就是文件相对路径,查找 O(1)
|
||||||
|
- 去掉 `Component` 概念——当前只有一个 `app` 组件,分层没有实际意义
|
||||||
|
- `FormatVersion` 固定为 `"2.0"`,与旧格式区分
|
||||||
|
|
||||||
|
## 6. Comparator 工作流改造
|
||||||
|
|
||||||
|
### 6.1 保留两个工作流
|
||||||
|
|
||||||
|
- **Comparator**(`plonds-comparator.yml`):比较文件生成器,只负责生成 `changed.zip` + `PLONDS.json`
|
||||||
|
- **Publisher**(`plonds-uploader.yml`):发布器,负责用仓库内 C# S3 客户端上传 `changed.zip`、`PLONDS.json` 和解压后的 `<version>-changed/` 目录,并把 GitHub/S3 下载信息写回 `PLONDS.json`
|
||||||
|
- **Rollback**:独立 rollback 工作流已废弃,不再维护
|
||||||
|
|
||||||
|
### 6.2 Comparator 改造后步骤
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
# plonds-comparator.yml
|
||||||
|
触发: release.published / release.prereleased / workflow_dispatch
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
compare:
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
steps:
|
||||||
|
- Checkout
|
||||||
|
|
||||||
|
- 解析发布上下文
|
||||||
|
→ RELEASE_TAG, RELEASE_VERSION, RELEASE_CHANNEL
|
||||||
|
|
||||||
|
- Setup .NET
|
||||||
|
|
||||||
|
- 构建 PLONDS Tool
|
||||||
|
|
||||||
|
- 解析基线版本
|
||||||
|
→ 查找上一个同频道 Release
|
||||||
|
→ 如果有 → 记录 baseline_tag, baseline_version
|
||||||
|
→ 如果没有 → is_full_update=true
|
||||||
|
|
||||||
|
- 下载 payload zips
|
||||||
|
→ 下载当前版本 files-windows-x64.zip
|
||||||
|
→ 下载基线版本 files-windows-x64.zip (如果有)
|
||||||
|
|
||||||
|
- 运行 build-delta
|
||||||
|
→ dotnet run Plonds.Tool -- build-delta \
|
||||||
|
--platform windows-x64 \
|
||||||
|
--current-version $VERSION \
|
||||||
|
--current-zip files-windows-x64.zip \
|
||||||
|
--output-dir plonds-output \
|
||||||
|
--channel $CHANNEL \
|
||||||
|
[--baseline-version $BASELINE_VERSION] \
|
||||||
|
[--baseline-zip baseline-files-windows-x64.zip]
|
||||||
|
|
||||||
|
- 上传到 GitHub Release
|
||||||
|
→ gh release upload changed.zip PLONDS.json
|
||||||
|
|
||||||
|
- 传递元数据给 Publisher
|
||||||
|
→ 上传 artifact: plonds-run-metadata (tag.txt)
|
||||||
|
```
|
||||||
|
|
||||||
|
### 6.3 Publisher 改造后步骤
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
# plonds-uploader.yml
|
||||||
|
触发: PLONDS Comparator completed / workflow_dispatch
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
publish:
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
steps:
|
||||||
|
- Checkout
|
||||||
|
- 解析 release tag
|
||||||
|
- Setup .NET
|
||||||
|
- 构建 PLONDS Tool
|
||||||
|
- 从 GitHub Release 下载 changed.zip + PLONDS.json
|
||||||
|
- 调用 dotnet run Plonds.Tool -- publish-s3
|
||||||
|
→ 使用仓库内 C# S3 客户端上传,不依赖 aws CLI
|
||||||
|
→ S3 目录布局:
|
||||||
|
<prefix>/<version>/PLONDS.json
|
||||||
|
<prefix>/<version>/changed.zip
|
||||||
|
<prefix>/<version>/<version>-changed/**
|
||||||
|
<prefix>/<version>/Files.zip
|
||||||
|
<prefix>/<version>/<version>-Files/**
|
||||||
|
→ 回写 PLONDS.json downloads 字段:
|
||||||
|
downloads.github.releaseUrl
|
||||||
|
downloads.github.manifestUrl
|
||||||
|
downloads.github.changedZipUrl
|
||||||
|
downloads.github.filesZipUrl
|
||||||
|
downloads.s3.manifestUrl
|
||||||
|
downloads.s3.changedZipUrl
|
||||||
|
downloads.s3.changedFolderUrl
|
||||||
|
downloads.s3.filesZipUrl
|
||||||
|
downloads.s3.filesFolderUrl
|
||||||
|
- 将回写后的 PLONDS.json 重新上传到 GitHub Release
|
||||||
|
```
|
||||||
|
|
||||||
|
### 6.4 与当前步骤的差异
|
||||||
|
|
||||||
|
| 当前步骤 | 改造后 |
|
||||||
|
|---------|--------|
|
||||||
|
| 准备签名密钥 | ❌ 删除 |
|
||||||
|
| 解析基线计划 (pwsh,三平台) | ✅ 简化:只找 Windows,逻辑简化 |
|
||||||
|
| 下载 payload zips (pwsh,三平台) | ✅ 简化:只下载 Windows |
|
||||||
|
| 构建增量资产 (pwsh,含 build-index + 静态布局验证 + plonds-static.zip 打包) | ✅ 简化:只调用 build-delta |
|
||||||
|
| 上传 PLONDS assets 到 release | ✅ 简化:只上传 changed.zip + PLONDS.json |
|
||||||
|
| 传递元数据 | ✅ 保留,但 artifact 内容简化 |
|
||||||
|
| Publisher 中使用 aws CLI / plonds-static / build-plonds / plonds.json.sig | ❌ 删除,改为 C# `publish-s3` |
|
||||||
|
| 独立 rollback workflow | ❌ 删除 |
|
||||||
|
|
||||||
|
## 7. 双模式差分生成
|
||||||
|
|
||||||
|
### 7.1 概述
|
||||||
|
|
||||||
|
Comparator 支持两种差分生成方法,通过 `workflow_dispatch` 的 `compare_method` 输入项选择:
|
||||||
|
|
||||||
|
| 方法 | 标识 | 核心思路 |
|
||||||
|
|------|------|---------|
|
||||||
|
| 方法一 | `file-compare` | 下载两个版本的 files zip,全量文件哈希对比 |
|
||||||
|
| 方法二 | `commit-analyze` | 分析两个版本之间的 git commit,映射源码变更到产物文件 |
|
||||||
|
|
||||||
|
### 7.2 GitHub Actions 触发器新增输入项
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
workflow_dispatch:
|
||||||
|
inputs:
|
||||||
|
tag: ...
|
||||||
|
baseline_tag: ...
|
||||||
|
channel: ...
|
||||||
|
compare_method: # 新增
|
||||||
|
description: '比较方法'
|
||||||
|
type: choice
|
||||||
|
default: file-compare
|
||||||
|
options:
|
||||||
|
- file-compare
|
||||||
|
- commit-analyze
|
||||||
|
hash_algorithm: # 新增(仅方法一)
|
||||||
|
description: '哈希算法'
|
||||||
|
type: choice
|
||||||
|
default: sha256
|
||||||
|
options:
|
||||||
|
- sha256
|
||||||
|
- md5
|
||||||
|
```
|
||||||
|
|
||||||
|
当由 `release` 事件触发时,默认使用 `file-compare` + `sha256`。
|
||||||
|
|
||||||
|
### 7.3 方法一:文件对比模式(file-compare)
|
||||||
|
|
||||||
|
**流程:**
|
||||||
|
|
||||||
|
```
|
||||||
|
1. 下载当前版本 files-windows-x64.zip
|
||||||
|
2. 下载基线版本 files-windows-x64.zip(如果有)
|
||||||
|
3. 解压两个 zip 到临时目录
|
||||||
|
4. 用指定哈希算法(sha256/md5)扫描两个目录的所有文件
|
||||||
|
5. 对比哈希值,生成 filesMap(add/replace/reuse/delete)
|
||||||
|
6. 从当前版本目录中提取 add/replace 的文件 → changed.zip
|
||||||
|
7. 生成 PLONDS.json
|
||||||
|
```
|
||||||
|
|
||||||
|
**PlondsDeltaBuildOptions 新增参数:**
|
||||||
|
|
||||||
|
```csharp
|
||||||
|
string HashAlgorithm = "sha256" // "sha256" | "md5"
|
||||||
|
```
|
||||||
|
|
||||||
|
**哈希算法对 PLONDS.json 的影响:**
|
||||||
|
|
||||||
|
- `sha256`:`filesMap` 和 `changedFilesMap` 中使用 `sha256` 字段
|
||||||
|
- `md5`:`filesMap` 和 `changedFilesMap` 中使用 `md5` 字段
|
||||||
|
|
||||||
|
### 7.4 方法二:Commit 分析模式(commit-analyze)
|
||||||
|
|
||||||
|
**流程:**
|
||||||
|
|
||||||
|
```
|
||||||
|
1. 下载当前版本 files-windows-x64.zip
|
||||||
|
2. 解压到临时目录
|
||||||
|
3. git log --name-only baseline_tag..current_tag
|
||||||
|
→ 得到两个版本之间的 commit 列表和涉及的源码文件
|
||||||
|
4. 过滤:只保留源码目录下的文件
|
||||||
|
5. 用简单规则映射源码文件到产物文件
|
||||||
|
6. 从当前版本的解压目录中提取映射到的产物文件 → changed.zip
|
||||||
|
7. 生成 PLONDS.json
|
||||||
|
8. 如果没有源码变更 → 自动回退到方法一
|
||||||
|
```
|
||||||
|
|
||||||
|
**源码目录过滤规则:**
|
||||||
|
|
||||||
|
只分析以下目录下的文件变更:
|
||||||
|
|
||||||
|
| 目录 | 说明 |
|
||||||
|
|------|------|
|
||||||
|
| `LanMountainDesktop/` | 主宿主应用 |
|
||||||
|
| `LanMountainDesktop.Launcher/` | 启动器 |
|
||||||
|
| `LanMountainDesktop.Shared.Contracts/` | 共享契约 |
|
||||||
|
| `LanMountainDesktop.PluginSdk/` | 插件 SDK |
|
||||||
|
| `LanMountainDesktop.Appearance/` | 外观系统 |
|
||||||
|
| `LanMountainDesktop.Settings.Core/` | 设置核心 |
|
||||||
|
| `LanMountainDesktop.ComponentSystem/` | 组件系统 |
|
||||||
|
|
||||||
|
忽略的目录:`docs/`、`scripts/`、`.github/`、`.trae/`、`PenguinLogisticsOnlineNetworkDistributionSystem/`
|
||||||
|
|
||||||
|
**源码到产物的映射规则:**
|
||||||
|
|
||||||
|
| 源码路径模式 | 映射到产物文件 |
|
||||||
|
|-------------|--------------|
|
||||||
|
| `LanMountainDesktop/**/*.{cs,axaml,xaml}` | `LanMountainDesktop.dll`, `LanMountainDesktop.exe` |
|
||||||
|
| `LanMountainDesktop.Launcher/**/*.{cs,axaml,xaml}` | `LanMountainDesktop.Launcher.exe` |
|
||||||
|
| `LanMountainDesktop.Shared.Contracts/**/*.cs` | `LanMountainDesktop.Shared.Contracts.dll` |
|
||||||
|
| `LanMountainDesktop.PluginSdk/**/*.cs` | `LanMountainDesktop.PluginSdk.dll` |
|
||||||
|
| `LanMountainDesktop.Appearance/**/*.cs` | `LanMountainDesktop.Appearance.dll` |
|
||||||
|
| `LanMountainDesktop.Settings.Core/**/*.cs` | `LanMountainDesktop.Settings.Core.dll` |
|
||||||
|
| `LanMountainDesktop.ComponentSystem/**/*.cs` | `LanMountainDesktop.ComponentSystem.dll` |
|
||||||
|
| `**/*.json`(配置文件) | 同路径的 .json |
|
||||||
|
| 其他无法映射的变更 | 保守标记 → 所有核心 .dll/.exe |
|
||||||
|
|
||||||
|
**方法二在 Plonds.Tool 中的新命令:**
|
||||||
|
|
||||||
|
```
|
||||||
|
build-delta-from-commits --platform <platform>
|
||||||
|
--current-version <version>
|
||||||
|
--current-zip <file>
|
||||||
|
--output-dir <dir>
|
||||||
|
--channel <channel>
|
||||||
|
--baseline-tag <tag>
|
||||||
|
--current-tag <tag>
|
||||||
|
[--source-dirs <dir1,dir2,...>]
|
||||||
|
[--fallback-zip <file>]
|
||||||
|
```
|
||||||
|
|
||||||
|
| 参数 | 必需 | 说明 |
|
||||||
|
|------|------|------|
|
||||||
|
| `--platform` | 是 | 平台标识 |
|
||||||
|
| `--current-version` | 是 | 当前发布版本号 |
|
||||||
|
| `--current-zip` | 是 | 当前版本的 payload zip |
|
||||||
|
| `--output-dir` | 是 | 输出目录 |
|
||||||
|
| `--channel` | 是 | 更新通道 |
|
||||||
|
| `--baseline-tag` | 是 | 基线版本的 git tag |
|
||||||
|
| `--current-tag` | 是 | 当前版本的 git tag |
|
||||||
|
| `--source-dirs` | 否 | 要分析的源码目录列表(逗号分隔) |
|
||||||
|
| `--fallback-zip` | 否 | 回退到方法一时使用的基线 zip |
|
||||||
|
|
||||||
|
**回退逻辑:**
|
||||||
|
|
||||||
|
如果 `git log` 分析后发现没有源码目录下的文件变更(比如只有 docs/ 变更),则自动回退到方法一:
|
||||||
|
1. 如果提供了 `--fallback-zip` → 用方法一对比两个 zip
|
||||||
|
2. 如果没有提供 → 生成全量更新(`isFullUpdate=true`)
|
||||||
|
|
||||||
|
### 7.5 方法二的 PLONDS.json 特殊处理
|
||||||
|
|
||||||
|
方法二无法像方法一那样生成完整的 `filesMap`(因为不知道哪些文件是 reuse 的),因此:
|
||||||
|
|
||||||
|
- `filesMap` 只包含映射到的变更文件(标记为 `add` 或 `replace`)
|
||||||
|
- 不包含 `reuse` 和 `delete` 条目
|
||||||
|
- `isFullUpdate` 始终为 `false`(除非回退到方法一且无基线)
|
||||||
|
- `requiresCleanInstall` 根据 Launcher.exe 是否在映射到的变更文件列表中判断
|
||||||
|
|
||||||
|
### 7.6 工作流中的条件分支
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
- name: Run build-delta
|
||||||
|
shell: bash
|
||||||
|
run: |
|
||||||
|
if [[ "$COMPARE_METHOD" == "commit-analyze" ]]; then
|
||||||
|
# 方法二
|
||||||
|
dotnet run --project ... -- build-delta-from-commits \
|
||||||
|
--platform windows-x64 \
|
||||||
|
--current-version $RELEASE_VERSION \
|
||||||
|
--current-zip $PWD/plonds-input/current-files-windows-x64.zip \
|
||||||
|
--output-dir $PWD/plonds-output \
|
||||||
|
--channel $RELEASE_CHANNEL \
|
||||||
|
--baseline-tag $BASELINE_TAG \
|
||||||
|
--current-tag $RELEASE_TAG \
|
||||||
|
--fallback-zip $PWD/plonds-input/baseline-files-windows-x64.zip
|
||||||
|
else
|
||||||
|
# 方法一
|
||||||
|
dotnet run --project ... -- build-delta \
|
||||||
|
--platform windows-x64 \
|
||||||
|
--current-version $RELEASE_VERSION \
|
||||||
|
--current-zip $PWD/plonds-input/current-files-windows-x64.zip \
|
||||||
|
--output-dir $PWD/plonds-output \
|
||||||
|
--channel $RELEASE_CHANNEL \
|
||||||
|
--hash-algorithm $HASH_ALGORITHM \
|
||||||
|
--baseline-version $BASELINE_VERSION \
|
||||||
|
--baseline-zip $PWD/plonds-input/baseline-files-windows-x64.zip
|
||||||
|
fi
|
||||||
|
```
|
||||||
|
|
||||||
|
方法二时,基线 zip 仍然需要下载(用于回退),但不需要解压(除非回退)。
|
||||||
|
|
||||||
|
### 7.7 两种方法的步骤差异
|
||||||
|
|
||||||
|
| 步骤 | 方法一 (file-compare) | 方法二 (commit-analyze) |
|
||||||
|
|------|----------------------|------------------------|
|
||||||
|
| 下载基线 zip | ✅ 需要 | ✅ 需要(用于回退) |
|
||||||
|
| 下载当前 zip | ✅ | ✅ |
|
||||||
|
| 解压两个 zip | ✅ | ✅ 只解压当前(回退时解压基线) |
|
||||||
|
| git diff/log | ❌ | ✅ 需要 fetch-depth:0 |
|
||||||
|
| 哈希对比 | ✅ 两个目录全量扫描 | ❌ 不做(除非回退) |
|
||||||
|
| 源码→产物映射 | ❌ | ✅ |
|
||||||
|
| 回退逻辑 | ❌ | ✅ 无源码变更时回退方法一 |
|
||||||
|
|
||||||
|
## 8. 不在本次改造范围内的事项
|
||||||
|
|
||||||
|
- 宿主侧客户端代码改造(PlondsUpdateApplier 等,后续单独设计)
|
||||||
|
- Launcher 侧客户端代码改造(后续单独设计)
|
||||||
|
- Plonds.Api 项目处置(后续决定是否保留)
|
||||||
|
- `build-index`、`generate`、`publish`、`sign` 等旧 Tool 命令的清理(后续处理)
|
||||||
@@ -8,7 +8,10 @@ Rebuild the settings window as an independent Fluent shell with a custom titleba
|
|||||||
|
|
||||||
- Keep the existing independent settings-window lifecycle: open-or-focus, no owner anchor, own taskbar entry.
|
- Keep the existing independent settings-window lifecycle: open-or-focus, no owner anchor, own taskbar entry.
|
||||||
- Use a 48 DIP titlebar with Back, pane toggle, icon/title, search, restart action, more menu, and caption-button spacer.
|
- Use a 48 DIP titlebar with Back, pane toggle, icon/title, search, restart action, more menu, and caption-button spacer.
|
||||||
|
- Keep the titlebar and content area on one shared full-window background layer; the custom titlebar must remain transparent and must not paint a contrasting strip.
|
||||||
|
- Avoid a visible titlebar bottom divider that makes the titlebar read as a separate color band.
|
||||||
- Keep `FANavigationView` as the primary navigation surface with `OpenPaneLength` around 283 DIP.
|
- Keep `FANavigationView` as the primary navigation surface with `OpenPaneLength` around 283 DIP.
|
||||||
|
- Keep `FANavigationView` pane and content template backgrounds transparent in the settings shell so the navigation control does not reintroduce a second surface color.
|
||||||
- Move the compact/minimal pane toggle from the navigation footer into the titlebar.
|
- Move the compact/minimal pane toggle from the navigation footer into the titlebar.
|
||||||
- Add search over built-in settings pages and settings expanders; selecting a result navigates, expands, focuses, and highlights.
|
- Add search over built-in settings pages and settings expanders; selecting a result navigates, expands, focuses, and highlights.
|
||||||
- Add `auto` system material mode and make it the default.
|
- Add `auto` system material mode and make it the default.
|
||||||
|
|||||||
@@ -15,7 +15,7 @@ Make the Settings > Update page the single user-facing control surface for the h
|
|||||||
- Users can opt into forced reinstall. When enabled, the update check targets the current version manifest where available and the UI labels the next payload as reinstall.
|
- Users can opt into forced reinstall. When enabled, the update check targets the current version manifest where available and the UI labels the next payload as reinstall.
|
||||||
- The page displays whether the current payload is an incremental update or reinstall/full installer.
|
- The page displays whether the current payload is an incremental update or reinstall/full installer.
|
||||||
- The page exposes pause, resume, and cancel actions for resumable downloads and install recovery.
|
- The page exposes pause, resume, and cancel actions for resumable downloads and install recovery.
|
||||||
- Existing PloNDS/FileMap incremental update and Launcher rollback ownership remain unchanged.
|
- Existing PloNDS/FileMap incremental update behavior remains, but update apply and rollback ownership belongs to the Host. Launcher only selects and starts the current app version.
|
||||||
|
|
||||||
## Acceptance
|
## Acceptance
|
||||||
|
|
||||||
|
|||||||
@@ -8,7 +8,7 @@ This spec is deprecated and superseded by `.trae/specs/pdc-incremental-migration
|
|||||||
|
|
||||||
- VeloPack native package generation introduced unstable release blocking (version format coupling and platform divergence).
|
- VeloPack native package generation introduced unstable release blocking (version format coupling and platform divergence).
|
||||||
- The project has switched back to signed FileMap incremental assets as the primary update path.
|
- The project has switched back to signed FileMap incremental assets as the primary update path.
|
||||||
- Launcher remains the update installer/rollback authority; packaging and distribution are being migrated to PDC/S3-compatible flows.
|
- Host owns update install and rollback authority; packaging and distribution are being migrated to PDC/S3-compatible flows. Launcher only selects and starts the current app version.
|
||||||
|
|
||||||
## Migration Note
|
## Migration Note
|
||||||
|
|
||||||
|
|||||||
1125
CODE_WIKI.md
1125
CODE_WIKI.md
File diff suppressed because it is too large
Load Diff
14
CheckIpcAot/CheckIpcAot.csproj
Normal file
14
CheckIpcAot/CheckIpcAot.csproj
Normal file
@@ -0,0 +1,14 @@
|
|||||||
|
<Project Sdk="Microsoft.NET.Sdk">
|
||||||
|
|
||||||
|
<PropertyGroup>
|
||||||
|
<OutputType>Exe</OutputType>
|
||||||
|
<TargetFramework>net10.0</TargetFramework>
|
||||||
|
<ImplicitUsings>enable</ImplicitUsings>
|
||||||
|
<Nullable>enable</Nullable>
|
||||||
|
</PropertyGroup>
|
||||||
|
|
||||||
|
<ItemGroup>
|
||||||
|
<PackageReference Include="dotnetCampus.Ipc" />
|
||||||
|
</ItemGroup>
|
||||||
|
|
||||||
|
</Project>
|
||||||
10
CheckIpcAot/Program.cs
Normal file
10
CheckIpcAot/Program.cs
Normal file
@@ -0,0 +1,10 @@
|
|||||||
|
using dotnetCampus.Ipc.CompilerServices.Attributes;
|
||||||
|
using System.Threading.Tasks;
|
||||||
|
|
||||||
|
[IpcPublic]
|
||||||
|
public interface IMyService {
|
||||||
|
Task<MyResult> DoWork(MyRequest req);
|
||||||
|
}
|
||||||
|
|
||||||
|
public class MyResult { public string Msg {get;set;} }
|
||||||
|
public class MyRequest { public string Data {get;set;} }
|
||||||
@@ -3,21 +3,21 @@
|
|||||||
<ManagePackageVersionsCentrally>true</ManagePackageVersionsCentrally>
|
<ManagePackageVersionsCentrally>true</ManagePackageVersionsCentrally>
|
||||||
</PropertyGroup>
|
</PropertyGroup>
|
||||||
<ItemGroup>
|
<ItemGroup>
|
||||||
<PackageVersion Include="Avalonia" Version="12.0.2" />
|
<PackageVersion Include="Avalonia" Version="12.0.3" />
|
||||||
<PackageVersion Include="Avalonia.Controls.WebView" Version="12.0.0" />
|
<PackageVersion Include="Avalonia.Controls.WebView" Version="12.0.1" />
|
||||||
<PackageVersion Include="Avalonia.Desktop" Version="12.0.2" />
|
<PackageVersion Include="Avalonia.Desktop" Version="12.0.3" />
|
||||||
<PackageVersion Include="Avalonia.Fonts.Inter" Version="12.0.2" />
|
<PackageVersion Include="Avalonia.Fonts.Inter" Version="12.0.3" />
|
||||||
<PackageVersion Include="Avalonia.Themes.Fluent" Version="12.0.2" />
|
<PackageVersion Include="Avalonia.Themes.Fluent" Version="12.0.3" />
|
||||||
<PackageVersion Include="AvaloniaUI.DiagnosticsSupport" Version="2.2.1" />
|
<PackageVersion Include="AvaloniaUI.DiagnosticsSupport" Version="2.2.1" />
|
||||||
<PackageVersion Include="ClassIsland.Markdown.Avalonia" Version="12.0.0" />
|
<PackageVersion Include="ClassIsland.Markdown.Avalonia" Version="12.0.0" />
|
||||||
<PackageVersion Include="CommunityToolkit.Mvvm" Version="8.4.2" />
|
<PackageVersion Include="CommunityToolkit.Mvvm" Version="8.4.2" />
|
||||||
<PackageVersion Include="dotnetCampus.Ipc" Version="2.0.0-alpha436" />
|
<PackageVersion Include="dotnetCampus.Ipc" Version="2.0.0-alpha436" />
|
||||||
<PackageVersion Include="DotNetCampus.AvaloniaInkCanvas" Version="1.0.1" />
|
<PackageVersion Include="DotNetCampus.AvaloniaInkCanvas" Version="1.0.1" />
|
||||||
<PackageVersion Include="Downloader" Version="5.4.0" />
|
<PackageVersion Include="Downloader" Version="5.4.0" />
|
||||||
<PackageVersion Include="FluentAvaloniaUI" Version="3.0.0-preview2" />
|
<PackageVersion Include="FluentAvaloniaUI" Version="3.0.0-preview4" />
|
||||||
<PackageVersion Include="FluentIcons.Avalonia" Version="2.1.325" />
|
<PackageVersion Include="FluentIcons.Avalonia" Version="2.1.325" />
|
||||||
<PackageVersion Include="Lib.Harmony.Thin" Version="2.4.2" />
|
<PackageVersion Include="Lib.Harmony.Thin" Version="2.4.2" />
|
||||||
<PackageVersion Include="Material.Avalonia" Version="3.16.1" />
|
<PackageVersion Include="Material.Avalonia" Version="3.17.0" />
|
||||||
<PackageVersion Include="MaterialColorUtilities" Version="0.3.0" />
|
<PackageVersion Include="MaterialColorUtilities" Version="0.3.0" />
|
||||||
<PackageVersion Include="Material.Icons.Avalonia" Version="3.0.3-nightly.0.2" />
|
<PackageVersion Include="Material.Icons.Avalonia" Version="3.0.3-nightly.0.2" />
|
||||||
<PackageVersion Include="Microsoft.Data.Sqlite" Version="11.0.0-preview.3.26207.106" />
|
<PackageVersion Include="Microsoft.Data.Sqlite" Version="11.0.0-preview.3.26207.106" />
|
||||||
@@ -30,8 +30,8 @@
|
|||||||
<PackageVersion Include="MudTools.OfficeInterop.PowerPoint" Version="2.0.9" />
|
<PackageVersion Include="MudTools.OfficeInterop.PowerPoint" Version="2.0.9" />
|
||||||
<PackageVersion Include="MudTools.OfficeInterop.Word" Version="2.0.9" />
|
<PackageVersion Include="MudTools.OfficeInterop.Word" Version="2.0.9" />
|
||||||
<PackageVersion Include="PortAudioSharp2" Version="1.0.6" />
|
<PackageVersion Include="PortAudioSharp2" Version="1.0.6" />
|
||||||
<PackageVersion Include="PostHog" Version="2.6.0" />
|
<PackageVersion Include="PostHog" Version="2.7.1" />
|
||||||
<PackageVersion Include="Sentry" Version="6.4.1" />
|
<PackageVersion Include="Sentry" Version="6.5.0" />
|
||||||
<PackageVersion Include="System.Drawing.Common" Version="11.0.0-preview.3.26207.106" />
|
<PackageVersion Include="System.Drawing.Common" Version="11.0.0-preview.3.26207.106" />
|
||||||
<PackageVersion Include="System.Runtime.WindowsRuntime" Version="5.0.0-preview.5.20278.1" />
|
<PackageVersion Include="System.Runtime.WindowsRuntime" Version="5.0.0-preview.5.20278.1" />
|
||||||
<PackageVersion Include="Tmds.DBus.Protocol" Version="0.92.0" />
|
<PackageVersion Include="Tmds.DBus.Protocol" Version="0.92.0" />
|
||||||
|
|||||||
@@ -1,10 +1,15 @@
|
|||||||
namespace LanMountainDesktop.Launcher.Services.AirApp;
|
namespace LanMountainDesktop.AirAppRuntime;
|
||||||
|
|
||||||
internal sealed class AirAppHostLocator
|
internal sealed class AirAppHostLocator
|
||||||
{
|
{
|
||||||
private const string WindowsExecutableName = "LanMountainDesktop.AirAppHost.exe";
|
private const string WindowsExecutableName = "LanMountainDesktop.AirAppHost.exe";
|
||||||
|
private const string UnixExecutableName = "LanMountainDesktop.AirAppHost";
|
||||||
private const string DllName = "LanMountainDesktop.AirAppHost.dll";
|
private const string DllName = "LanMountainDesktop.AirAppHost.dll";
|
||||||
|
|
||||||
|
private static string ExecutableName => OperatingSystem.IsWindows()
|
||||||
|
? WindowsExecutableName
|
||||||
|
: UnixExecutableName;
|
||||||
|
|
||||||
public string Resolve(string? packageRoot, string? hostPath = null)
|
public string Resolve(string? packageRoot, string? hostPath = null)
|
||||||
{
|
{
|
||||||
foreach (var candidate in EnumerateCandidates(packageRoot, hostPath))
|
foreach (var candidate in EnumerateCandidates(packageRoot, hostPath))
|
||||||
@@ -22,18 +27,18 @@ internal sealed class AirAppHostLocator
|
|||||||
{
|
{
|
||||||
foreach (var root in EnumerateRoots(packageRoot, hostPath))
|
foreach (var root in EnumerateRoots(packageRoot, hostPath))
|
||||||
{
|
{
|
||||||
yield return Path.Combine(root, "AirAppHost", WindowsExecutableName);
|
yield return Path.Combine(root, "AirAppHost", ExecutableName);
|
||||||
yield return Path.Combine(root, "AirAppHost", DllName);
|
yield return Path.Combine(root, "AirAppHost", DllName);
|
||||||
yield return Path.Combine(root, WindowsExecutableName);
|
yield return Path.Combine(root, ExecutableName);
|
||||||
yield return Path.Combine(root, DllName);
|
yield return Path.Combine(root, DllName);
|
||||||
|
|
||||||
if (Directory.Exists(root))
|
if (Directory.Exists(root))
|
||||||
{
|
{
|
||||||
foreach (var deploymentDirectory in Directory.GetDirectories(root, "app-*", SearchOption.TopDirectoryOnly))
|
foreach (var deploymentDirectory in Directory.GetDirectories(root, "app-*", SearchOption.TopDirectoryOnly))
|
||||||
{
|
{
|
||||||
yield return Path.Combine(deploymentDirectory, "AirAppHost", WindowsExecutableName);
|
yield return Path.Combine(deploymentDirectory, "AirAppHost", ExecutableName);
|
||||||
yield return Path.Combine(deploymentDirectory, "AirAppHost", DllName);
|
yield return Path.Combine(deploymentDirectory, "AirAppHost", DllName);
|
||||||
yield return Path.Combine(deploymentDirectory, WindowsExecutableName);
|
yield return Path.Combine(deploymentDirectory, ExecutableName);
|
||||||
yield return Path.Combine(deploymentDirectory, DllName);
|
yield return Path.Combine(deploymentDirectory, DllName);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -52,7 +57,7 @@ internal sealed class AirAppHostLocator
|
|||||||
"Release",
|
"Release",
|
||||||
#endif
|
#endif
|
||||||
"net10.0",
|
"net10.0",
|
||||||
WindowsExecutableName);
|
ExecutableName);
|
||||||
|
|
||||||
yield return Path.Combine(
|
yield return Path.Combine(
|
||||||
current.FullName,
|
current.FullName,
|
||||||
@@ -1,4 +1,4 @@
|
|||||||
namespace LanMountainDesktop.Launcher.Services.AirApp;
|
namespace LanMountainDesktop.AirAppRuntime;
|
||||||
|
|
||||||
internal static class AirAppInstanceKey
|
internal static class AirAppInstanceKey
|
||||||
{
|
{
|
||||||
@@ -17,8 +17,6 @@ internal static class AirAppInstanceKey
|
|||||||
|
|
||||||
private static string Normalize(string? value, string fallback)
|
private static string Normalize(string? value, string fallback)
|
||||||
{
|
{
|
||||||
return string.IsNullOrWhiteSpace(value)
|
return string.IsNullOrWhiteSpace(value) ? fallback : value.Trim();
|
||||||
? fallback
|
|
||||||
: value.Trim();
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -2,15 +2,15 @@ using System.Diagnostics;
|
|||||||
using System.Runtime.InteropServices;
|
using System.Runtime.InteropServices;
|
||||||
using LanMountainDesktop.Shared.IPC.Abstractions.Services;
|
using LanMountainDesktop.Shared.IPC.Abstractions.Services;
|
||||||
|
|
||||||
namespace LanMountainDesktop.Launcher.Services.AirApp;
|
namespace LanMountainDesktop.AirAppRuntime;
|
||||||
|
|
||||||
internal sealed class LauncherAirAppLifecycleService : IAirAppLifecycleService
|
internal sealed class AirAppLifecycleService : IAirAppLifecycleService
|
||||||
{
|
{
|
||||||
private readonly object _gate = new();
|
private readonly object _gate = new();
|
||||||
private readonly IAirAppProcessStarter _processStarter;
|
private readonly IAirAppProcessStarter _processStarter;
|
||||||
private readonly Dictionary<string, ManagedAirAppInstance> _instances = new(StringComparer.OrdinalIgnoreCase);
|
private readonly Dictionary<string, ManagedAirAppInstance> _instances = new(StringComparer.OrdinalIgnoreCase);
|
||||||
|
|
||||||
public LauncherAirAppLifecycleService(IAirAppProcessStarter processStarter)
|
public AirAppLifecycleService(IAirAppProcessStarter processStarter)
|
||||||
{
|
{
|
||||||
_processStarter = processStarter;
|
_processStarter = processStarter;
|
||||||
}
|
}
|
||||||
@@ -20,7 +20,7 @@ internal sealed class LauncherAirAppLifecycleService : IAirAppLifecycleService
|
|||||||
ArgumentNullException.ThrowIfNull(request);
|
ArgumentNullException.ThrowIfNull(request);
|
||||||
var appId = Normalize(request.AppId, "unknown");
|
var appId = Normalize(request.AppId, "unknown");
|
||||||
var instanceKey = AirAppInstanceKey.Build(appId, request.SourceComponentId, request.SourcePlacementId);
|
var instanceKey = AirAppInstanceKey.Build(appId, request.SourceComponentId, request.SourcePlacementId);
|
||||||
Logger.Info(
|
AirAppRuntimeLogger.Info(
|
||||||
$"Air APP open requested. AppId='{appId}'; InstanceKey='{instanceKey}'; RequesterProcessId={request.RequesterProcessId}.");
|
$"Air APP open requested. AppId='{appId}'; InstanceKey='{instanceKey}'; RequesterProcessId={request.RequesterProcessId}.");
|
||||||
|
|
||||||
lock (_gate)
|
lock (_gate)
|
||||||
@@ -57,12 +57,12 @@ internal sealed class LauncherAirAppLifecycleService : IAirAppLifecycleService
|
|||||||
request.SourceComponentId,
|
request.SourceComponentId,
|
||||||
request.SourcePlacementId);
|
request.SourcePlacementId);
|
||||||
_instances[instanceKey] = instance;
|
_instances[instanceKey] = instance;
|
||||||
Logger.Info($"Started Air APP. AppId='{appId}'; InstanceKey='{instanceKey}'; ProcessId={process.Id}.");
|
AirAppRuntimeLogger.Info($"Started Air APP. AppId='{appId}'; InstanceKey='{instanceKey}'; ProcessId={process.Id}.");
|
||||||
return Task.FromResult(BuildResult(true, "started", "Started Air APP instance.", instance));
|
return Task.FromResult(BuildResult(true, "started", "Started Air APP instance.", instance));
|
||||||
}
|
}
|
||||||
catch (Exception ex)
|
catch (Exception ex)
|
||||||
{
|
{
|
||||||
Logger.Warn($"Failed to start Air APP '{appId}': {ex.Message}");
|
AirAppRuntimeLogger.Warn($"Failed to start Air APP '{appId}': {ex.Message}");
|
||||||
return Task.FromResult(BuildResult(false, "start_failed", ex.Message, null));
|
return Task.FromResult(BuildResult(false, "start_failed", ex.Message, null));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -134,7 +134,7 @@ internal sealed class LauncherAirAppLifecycleService : IAirAppLifecycleService
|
|||||||
request.SourceComponentId,
|
request.SourceComponentId,
|
||||||
request.SourcePlacementId);
|
request.SourcePlacementId);
|
||||||
_instances[instanceKey] = instance;
|
_instances[instanceKey] = instance;
|
||||||
Logger.Info($"Registered Air APP. AppId='{instance.AppId}'; InstanceKey='{instanceKey}'; ProcessId={instance.ProcessId}.");
|
AirAppRuntimeLogger.Info($"Registered Air APP. AppId='{instance.AppId}'; InstanceKey='{instanceKey}'; ProcessId={instance.ProcessId}.");
|
||||||
return Task.FromResult(BuildResult(true, "registered", "Air APP instance registered.", instance));
|
return Task.FromResult(BuildResult(true, "registered", "Air APP instance registered.", instance));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -147,7 +147,7 @@ internal sealed class LauncherAirAppLifecycleService : IAirAppLifecycleService
|
|||||||
(processId <= 0 || instance.ProcessId == processId))
|
(processId <= 0 || instance.ProcessId == processId))
|
||||||
{
|
{
|
||||||
_instances.Remove(instanceKey);
|
_instances.Remove(instanceKey);
|
||||||
Logger.Info($"Unregistered Air APP. InstanceKey='{instanceKey}'; ProcessId={processId}.");
|
AirAppRuntimeLogger.Info($"Unregistered Air APP. InstanceKey='{instanceKey}'; ProcessId={processId}.");
|
||||||
return Task.FromResult(BuildResult(true, "unregistered", "Air APP instance unregistered.", instance));
|
return Task.FromResult(BuildResult(true, "unregistered", "Air APP instance unregistered.", instance));
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -174,7 +174,7 @@ internal sealed class LauncherAirAppLifecycleService : IAirAppLifecycleService
|
|||||||
foreach (var key in exitedKeys)
|
foreach (var key in exitedKeys)
|
||||||
{
|
{
|
||||||
_instances.Remove(key);
|
_instances.Remove(key);
|
||||||
Logger.Info($"Pruned exited Air APP instance. InstanceKey='{key}'.");
|
AirAppRuntimeLogger.Info($"Pruned exited Air APP instance. InstanceKey='{key}'.");
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -237,7 +237,7 @@ internal sealed class LauncherAirAppLifecycleService : IAirAppLifecycleService
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private static bool IsProcessAlive(int processId)
|
internal static bool IsProcessAlive(int processId)
|
||||||
{
|
{
|
||||||
if (processId <= 0)
|
if (processId <= 0)
|
||||||
{
|
{
|
||||||
@@ -257,9 +257,7 @@ internal sealed class LauncherAirAppLifecycleService : IAirAppLifecycleService
|
|||||||
|
|
||||||
private static string Normalize(string? value, string fallback)
|
private static string Normalize(string? value, string fallback)
|
||||||
{
|
{
|
||||||
return string.IsNullOrWhiteSpace(value)
|
return string.IsNullOrWhiteSpace(value) ? fallback : value.Trim();
|
||||||
? fallback
|
|
||||||
: value.Trim();
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private const int SW_SHOWNORMAL = 1;
|
private const int SW_SHOWNORMAL = 1;
|
||||||
@@ -0,0 +1,29 @@
|
|||||||
|
using LanMountainDesktop.Shared.IPC.Abstractions.Services;
|
||||||
|
|
||||||
|
namespace LanMountainDesktop.AirAppRuntime;
|
||||||
|
|
||||||
|
internal sealed class AirAppRuntimeControlService : IAirAppRuntimeControlService
|
||||||
|
{
|
||||||
|
private readonly AirAppRuntimeLifetime _lifetime;
|
||||||
|
|
||||||
|
public AirAppRuntimeControlService(AirAppRuntimeLifetime lifetime)
|
||||||
|
{
|
||||||
|
_lifetime = lifetime;
|
||||||
|
}
|
||||||
|
|
||||||
|
public Task<AirAppRuntimeControlResult> AttachHostAsync(int hostProcessId)
|
||||||
|
{
|
||||||
|
_lifetime.AttachHost(hostProcessId);
|
||||||
|
var status = _lifetime.GetStatus();
|
||||||
|
return Task.FromResult(new AirAppRuntimeControlResult(
|
||||||
|
hostProcessId > 0,
|
||||||
|
hostProcessId > 0 ? "host_attached" : "invalid_host_pid",
|
||||||
|
hostProcessId > 0 ? "AirApp runtime host process attached." : "Host process id must be positive.",
|
||||||
|
status));
|
||||||
|
}
|
||||||
|
|
||||||
|
public Task<AirAppRuntimeStatus> GetStatusAsync()
|
||||||
|
{
|
||||||
|
return Task.FromResult(_lifetime.GetStatus());
|
||||||
|
}
|
||||||
|
}
|
||||||
29
LanMountainDesktop.AirAppRuntime/AirAppRuntimeIpcHost.cs
Normal file
29
LanMountainDesktop.AirAppRuntime/AirAppRuntimeIpcHost.cs
Normal file
@@ -0,0 +1,29 @@
|
|||||||
|
using LanMountainDesktop.Shared.IPC;
|
||||||
|
using LanMountainDesktop.Shared.IPC.Abstractions.Services;
|
||||||
|
|
||||||
|
namespace LanMountainDesktop.AirAppRuntime;
|
||||||
|
|
||||||
|
internal sealed class AirAppRuntimeIpcHost : IDisposable
|
||||||
|
{
|
||||||
|
private readonly PublicIpcHostService _host;
|
||||||
|
|
||||||
|
public AirAppRuntimeIpcHost(
|
||||||
|
AirAppLifecycleService lifecycleService,
|
||||||
|
AirAppRuntimeControlService controlService)
|
||||||
|
{
|
||||||
|
_host = new PublicIpcHostService(IpcConstants.AirAppRuntimePipeName);
|
||||||
|
_host.RegisterPublicService<IAirAppLifecycleService>(lifecycleService);
|
||||||
|
_host.RegisterPublicService<IAirAppRuntimeControlService>(controlService);
|
||||||
|
}
|
||||||
|
|
||||||
|
public void Start()
|
||||||
|
{
|
||||||
|
_host.Start();
|
||||||
|
AirAppRuntimeLogger.Info($"Air APP runtime IPC started. Pipe='{IpcConstants.AirAppRuntimePipeName}'.");
|
||||||
|
}
|
||||||
|
|
||||||
|
public void Dispose()
|
||||||
|
{
|
||||||
|
_host.Dispose();
|
||||||
|
}
|
||||||
|
}
|
||||||
77
LanMountainDesktop.AirAppRuntime/AirAppRuntimeLifetime.cs
Normal file
77
LanMountainDesktop.AirAppRuntime/AirAppRuntimeLifetime.cs
Normal file
@@ -0,0 +1,77 @@
|
|||||||
|
using LanMountainDesktop.Shared.IPC.Abstractions.Services;
|
||||||
|
|
||||||
|
namespace LanMountainDesktop.AirAppRuntime;
|
||||||
|
|
||||||
|
internal sealed class AirAppRuntimeLifetime
|
||||||
|
{
|
||||||
|
private readonly object _gate = new();
|
||||||
|
private readonly DateTimeOffset _startedAtUtc = DateTimeOffset.UtcNow;
|
||||||
|
private readonly AirAppLifecycleService _lifecycleService;
|
||||||
|
private readonly int _launcherProcessId;
|
||||||
|
private readonly int _requesterProcessId;
|
||||||
|
private int _hostProcessId;
|
||||||
|
private DateTimeOffset _updatedAtUtc;
|
||||||
|
|
||||||
|
public AirAppRuntimeLifetime(AirAppRuntimeOptions options, AirAppLifecycleService lifecycleService)
|
||||||
|
{
|
||||||
|
_lifecycleService = lifecycleService;
|
||||||
|
_launcherProcessId = options.LauncherProcessId;
|
||||||
|
_requesterProcessId = options.RequesterProcessId;
|
||||||
|
_hostProcessId = options.RequesterProcessId;
|
||||||
|
_updatedAtUtc = _startedAtUtc;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void AttachHost(int hostProcessId)
|
||||||
|
{
|
||||||
|
if (hostProcessId <= 0)
|
||||||
|
{
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
lock (_gate)
|
||||||
|
{
|
||||||
|
_hostProcessId = hostProcessId;
|
||||||
|
_updatedAtUtc = DateTimeOffset.UtcNow;
|
||||||
|
}
|
||||||
|
|
||||||
|
AirAppRuntimeLogger.Info($"Attached host process. HostPid={hostProcessId}.");
|
||||||
|
}
|
||||||
|
|
||||||
|
public bool ShouldKeepAlive()
|
||||||
|
{
|
||||||
|
var status = GetStatus();
|
||||||
|
return status.LauncherProcessAlive ||
|
||||||
|
status.HostProcessAlive ||
|
||||||
|
IsProcessAlive(_requesterProcessId) ||
|
||||||
|
status.HasLiveAirApps;
|
||||||
|
}
|
||||||
|
|
||||||
|
public AirAppRuntimeStatus GetStatus()
|
||||||
|
{
|
||||||
|
int hostPid;
|
||||||
|
DateTimeOffset updatedAt;
|
||||||
|
lock (_gate)
|
||||||
|
{
|
||||||
|
hostPid = _hostProcessId;
|
||||||
|
updatedAt = _updatedAtUtc;
|
||||||
|
}
|
||||||
|
|
||||||
|
var launcherAlive = IsProcessAlive(_launcherProcessId);
|
||||||
|
var hostAlive = IsProcessAlive(hostPid);
|
||||||
|
var hasLiveAirApps = _lifecycleService.HasLiveAirApps();
|
||||||
|
return new AirAppRuntimeStatus(
|
||||||
|
Environment.ProcessId,
|
||||||
|
_launcherProcessId,
|
||||||
|
hostPid,
|
||||||
|
launcherAlive,
|
||||||
|
hostAlive,
|
||||||
|
hasLiveAirApps,
|
||||||
|
_startedAtUtc,
|
||||||
|
updatedAt);
|
||||||
|
}
|
||||||
|
|
||||||
|
internal static bool IsProcessAlive(int processId)
|
||||||
|
{
|
||||||
|
return AirAppLifecycleService.IsProcessAlive(processId);
|
||||||
|
}
|
||||||
|
}
|
||||||
16
LanMountainDesktop.AirAppRuntime/AirAppRuntimeLogger.cs
Normal file
16
LanMountainDesktop.AirAppRuntime/AirAppRuntimeLogger.cs
Normal file
@@ -0,0 +1,16 @@
|
|||||||
|
using System.Diagnostics;
|
||||||
|
|
||||||
|
namespace LanMountainDesktop.AirAppRuntime;
|
||||||
|
|
||||||
|
internal static class AirAppRuntimeLogger
|
||||||
|
{
|
||||||
|
public static void Info(string message) => Trace.WriteLine($"[AirAppRuntime] INFO {message}");
|
||||||
|
|
||||||
|
public static void Warn(string message) => Trace.WriteLine($"[AirAppRuntime] WARN {message}");
|
||||||
|
|
||||||
|
public static void Warn(string message, Exception ex) =>
|
||||||
|
Trace.WriteLine($"[AirAppRuntime] WARN {message} {ex}");
|
||||||
|
|
||||||
|
public static void Error(string message, Exception ex) =>
|
||||||
|
Trace.WriteLine($"[AirAppRuntime] ERROR {message} {ex}");
|
||||||
|
}
|
||||||
66
LanMountainDesktop.AirAppRuntime/AirAppRuntimeOptions.cs
Normal file
66
LanMountainDesktop.AirAppRuntime/AirAppRuntimeOptions.cs
Normal file
@@ -0,0 +1,66 @@
|
|||||||
|
using System.Globalization;
|
||||||
|
|
||||||
|
namespace LanMountainDesktop.AirAppRuntime;
|
||||||
|
|
||||||
|
internal sealed record AirAppRuntimeOptions(
|
||||||
|
string? AppRoot,
|
||||||
|
string? DataRoot,
|
||||||
|
int LauncherProcessId,
|
||||||
|
int RequesterProcessId)
|
||||||
|
{
|
||||||
|
public static AirAppRuntimeOptions Parse(IReadOnlyList<string> args)
|
||||||
|
{
|
||||||
|
var values = new Dictionary<string, string>(StringComparer.OrdinalIgnoreCase);
|
||||||
|
for (var index = 0; index < args.Count; index++)
|
||||||
|
{
|
||||||
|
var current = args[index];
|
||||||
|
if (!current.StartsWith("--", StringComparison.Ordinal))
|
||||||
|
{
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
var key = current[2..];
|
||||||
|
if (string.IsNullOrWhiteSpace(key))
|
||||||
|
{
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
var equalsIndex = key.IndexOf('=');
|
||||||
|
if (equalsIndex >= 0)
|
||||||
|
{
|
||||||
|
values[key[..equalsIndex]] = key[(equalsIndex + 1)..];
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (index + 1 < args.Count && !args[index + 1].StartsWith("--", StringComparison.Ordinal))
|
||||||
|
{
|
||||||
|
values[key] = args[++index];
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
values[key] = "true";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return new AirAppRuntimeOptions(
|
||||||
|
GetOptionalPath(values, "app-root"),
|
||||||
|
GetOptionalPath(values, "data-root"),
|
||||||
|
GetInt(values, "launcher-pid"),
|
||||||
|
GetInt(values, "requester-pid"));
|
||||||
|
}
|
||||||
|
|
||||||
|
private static string? GetOptionalPath(IReadOnlyDictionary<string, string> values, string key)
|
||||||
|
{
|
||||||
|
return values.TryGetValue(key, out var value) && !string.IsNullOrWhiteSpace(value)
|
||||||
|
? Path.GetFullPath(value)
|
||||||
|
: null;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static int GetInt(IReadOnlyDictionary<string, string> values, string key)
|
||||||
|
{
|
||||||
|
return values.TryGetValue(key, out var value) &&
|
||||||
|
int.TryParse(value, NumberStyles.Integer, CultureInfo.InvariantCulture, out var parsed)
|
||||||
|
? parsed
|
||||||
|
: 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,7 +1,7 @@
|
|||||||
using System.Diagnostics;
|
using System.Diagnostics;
|
||||||
using LanMountainDesktop.Launcher.Services;
|
using LanMountainDesktop.Shared.IPC;
|
||||||
|
|
||||||
namespace LanMountainDesktop.Launcher.Services.AirApp;
|
namespace LanMountainDesktop.AirAppRuntime;
|
||||||
|
|
||||||
internal interface IAirAppProcessStarter
|
internal interface IAirAppProcessStarter
|
||||||
{
|
{
|
||||||
@@ -14,20 +14,17 @@ internal sealed class AirAppProcessStarter : IAirAppProcessStarter
|
|||||||
private readonly Func<string?> _packageRootProvider;
|
private readonly Func<string?> _packageRootProvider;
|
||||||
private readonly Func<string?> _hostPathProvider;
|
private readonly Func<string?> _hostPathProvider;
|
||||||
private readonly Func<string?> _dataRootProvider;
|
private readonly Func<string?> _dataRootProvider;
|
||||||
private readonly DotNetRuntimeProbeOptions? _runtimeProbeOptions;
|
|
||||||
|
|
||||||
public AirAppProcessStarter(
|
public AirAppProcessStarter(
|
||||||
AirAppHostLocator locator,
|
AirAppHostLocator locator,
|
||||||
Func<string?> packageRootProvider,
|
Func<string?> packageRootProvider,
|
||||||
Func<string?> hostPathProvider,
|
Func<string?> hostPathProvider,
|
||||||
Func<string?> dataRootProvider,
|
Func<string?> dataRootProvider)
|
||||||
DotNetRuntimeProbeOptions? runtimeProbeOptions = null)
|
|
||||||
{
|
{
|
||||||
_locator = locator;
|
_locator = locator;
|
||||||
_packageRootProvider = packageRootProvider;
|
_packageRootProvider = packageRootProvider;
|
||||||
_hostPathProvider = hostPathProvider;
|
_hostPathProvider = hostPathProvider;
|
||||||
_dataRootProvider = dataRootProvider;
|
_dataRootProvider = dataRootProvider;
|
||||||
_runtimeProbeOptions = runtimeProbeOptions;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
public Process? Start(
|
public Process? Start(
|
||||||
@@ -38,12 +35,12 @@ internal sealed class AirAppProcessStarter : IAirAppProcessStarter
|
|||||||
string? sourcePlacementId)
|
string? sourcePlacementId)
|
||||||
{
|
{
|
||||||
var hostPath = _locator.Resolve(_packageRootProvider(), _hostPathProvider());
|
var hostPath = _locator.Resolve(_packageRootProvider(), _hostPathProvider());
|
||||||
var startInfo = CreateStartInfo(hostPath, _runtimeProbeOptions);
|
var startInfo = CreateStartInfo(hostPath);
|
||||||
|
|
||||||
AddArgument(startInfo, "--app-id", appId);
|
AddArgument(startInfo, "--app-id", appId);
|
||||||
AddArgument(startInfo, "--session-id", sessionId);
|
AddArgument(startInfo, "--session-id", sessionId);
|
||||||
AddArgument(startInfo, "--instance-key", instanceKey);
|
AddArgument(startInfo, "--instance-key", instanceKey);
|
||||||
AddArgument(startInfo, "--launcher-pipe", LanMountainDesktop.Shared.IPC.IpcConstants.AirAppLifecyclePipeName);
|
AddArgument(startInfo, "--launcher-pipe", IpcConstants.AirAppRuntimePipeName);
|
||||||
var dataRoot = _dataRootProvider();
|
var dataRoot = _dataRootProvider();
|
||||||
if (!string.IsNullOrWhiteSpace(dataRoot))
|
if (!string.IsNullOrWhiteSpace(dataRoot))
|
||||||
{
|
{
|
||||||
@@ -60,7 +57,7 @@ internal sealed class AirAppProcessStarter : IAirAppProcessStarter
|
|||||||
AddArgument(startInfo, "--source-placement-id", sourcePlacementId.Trim());
|
AddArgument(startInfo, "--source-placement-id", sourcePlacementId.Trim());
|
||||||
}
|
}
|
||||||
|
|
||||||
LanMountainDesktop.Launcher.Services.Logger.Info(
|
AirAppRuntimeLogger.Info(
|
||||||
$"Starting AirAppHost. AppId='{appId}'; InstanceKey='{instanceKey}'; HostPath='{hostPath}'; DataRoot='{dataRoot ?? string.Empty}'.");
|
$"Starting AirAppHost. AppId='{appId}'; InstanceKey='{instanceKey}'; HostPath='{hostPath}'; DataRoot='{dataRoot ?? string.Empty}'.");
|
||||||
var process = Process.Start(startInfo);
|
var process = Process.Start(startInfo);
|
||||||
if (process is not null)
|
if (process is not null)
|
||||||
@@ -70,12 +67,12 @@ internal sealed class AirAppProcessStarter : IAirAppProcessStarter
|
|||||||
{
|
{
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
LanMountainDesktop.Launcher.Services.Logger.Info(
|
AirAppRuntimeLogger.Info(
|
||||||
$"AirAppHost exited. AppId='{appId}'; InstanceKey='{instanceKey}'; ProcessId={process.Id}; ExitCode={process.ExitCode}.");
|
$"AirAppHost exited. AppId='{appId}'; InstanceKey='{instanceKey}'; ProcessId={process.Id}; ExitCode={process.ExitCode}.");
|
||||||
}
|
}
|
||||||
catch (Exception ex)
|
catch (Exception ex)
|
||||||
{
|
{
|
||||||
LanMountainDesktop.Launcher.Services.Logger.Warn($"Failed to log AirAppHost exit: {ex.Message}");
|
AirAppRuntimeLogger.Warn($"Failed to log AirAppHost exit: {ex.Message}");
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
@@ -83,51 +80,9 @@ internal sealed class AirAppProcessStarter : IAirAppProcessStarter
|
|||||||
return process;
|
return process;
|
||||||
}
|
}
|
||||||
|
|
||||||
internal static ProcessStartInfo CreateStartInfo(
|
internal static ProcessStartInfo CreateStartInfo(string hostPath)
|
||||||
string hostPath,
|
|
||||||
DotNetRuntimeProbeOptions? runtimeProbeOptions = null)
|
|
||||||
{
|
{
|
||||||
var startInfo = new ProcessStartInfo
|
return AirAppRuntimeProcessStarter.CreateStartInfo(hostPath);
|
||||||
{
|
|
||||||
UseShellExecute = false,
|
|
||||||
WorkingDirectory = Path.GetDirectoryName(hostPath) ?? AppContext.BaseDirectory
|
|
||||||
};
|
|
||||||
|
|
||||||
if (OperatingSystem.IsWindows())
|
|
||||||
{
|
|
||||||
if (string.Equals(Path.GetExtension(hostPath), ".exe", StringComparison.OrdinalIgnoreCase))
|
|
||||||
{
|
|
||||||
if (DotNetRuntimeProbe.IsFrameworkDependentWindowsApp(hostPath))
|
|
||||||
{
|
|
||||||
var executableRuntime = DotNetRuntimeProbe.Probe(runtimeProbeOptions);
|
|
||||||
if (!executableRuntime.IsAvailable)
|
|
||||||
{
|
|
||||||
throw new InvalidOperationException(
|
|
||||||
"Unable to start AirAppHost because the architecture-matched .NET 10 runtime was not found. " +
|
|
||||||
executableRuntime.Message);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
startInfo.FileName = hostPath;
|
|
||||||
return startInfo;
|
|
||||||
}
|
|
||||||
|
|
||||||
var runtime = DotNetRuntimeProbe.Probe(runtimeProbeOptions);
|
|
||||||
if (!runtime.IsAvailable || string.IsNullOrWhiteSpace(runtime.DotNetHostPath))
|
|
||||||
{
|
|
||||||
throw new InvalidOperationException(
|
|
||||||
"Unable to start AirAppHost because the architecture-matched .NET 10 runtime was not found. " +
|
|
||||||
runtime.Message);
|
|
||||||
}
|
|
||||||
|
|
||||||
startInfo.FileName = runtime.DotNetHostPath;
|
|
||||||
startInfo.ArgumentList.Add(hostPath);
|
|
||||||
return startInfo;
|
|
||||||
}
|
|
||||||
|
|
||||||
startInfo.FileName = "dotnet";
|
|
||||||
startInfo.ArgumentList.Add(hostPath);
|
|
||||||
return startInfo;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private static void AddArgument(ProcessStartInfo startInfo, string name, string value)
|
private static void AddArgument(ProcessStartInfo startInfo, string name, string value)
|
||||||
@@ -0,0 +1,20 @@
|
|||||||
|
<Project Sdk="Microsoft.NET.Sdk">
|
||||||
|
<PropertyGroup>
|
||||||
|
<OutputType>WinExe</OutputType>
|
||||||
|
<TargetFramework>net10.0</TargetFramework>
|
||||||
|
<RollForward>LatestMajor</RollForward>
|
||||||
|
<Nullable>enable</Nullable>
|
||||||
|
<ImplicitUsings>enable</ImplicitUsings>
|
||||||
|
<PublishAot>false</PublishAot>
|
||||||
|
<SelfContained>false</SelfContained>
|
||||||
|
<PublishSingleFile>false</PublishSingleFile>
|
||||||
|
<PublishTrimmed>false</PublishTrimmed>
|
||||||
|
<PublishReadyToRun>false</PublishReadyToRun>
|
||||||
|
<AvaloniaUseCompiledBindingsByDefault>true</AvaloniaUseCompiledBindingsByDefault>
|
||||||
|
<ApplicationIcon>..\LanMountainDesktop\Assets\logo_nightly.ico</ApplicationIcon>
|
||||||
|
</PropertyGroup>
|
||||||
|
|
||||||
|
<ItemGroup>
|
||||||
|
<ProjectReference Include="..\LanMountainDesktop.Shared.IPC\LanMountainDesktop.Shared.IPC.csproj" />
|
||||||
|
</ItemGroup>
|
||||||
|
</Project>
|
||||||
40
LanMountainDesktop.AirAppRuntime/Program.cs
Normal file
40
LanMountainDesktop.AirAppRuntime/Program.cs
Normal file
@@ -0,0 +1,40 @@
|
|||||||
|
namespace LanMountainDesktop.AirAppRuntime;
|
||||||
|
|
||||||
|
internal static class Program
|
||||||
|
{
|
||||||
|
public static async Task<int> Main(string[] args)
|
||||||
|
{
|
||||||
|
var options = AirAppRuntimeOptions.Parse(args);
|
||||||
|
AirAppRuntimeLogger.Info(
|
||||||
|
$"Starting. AppRoot='{options.AppRoot ?? string.Empty}'; DataRoot='{options.DataRoot ?? string.Empty}'; " +
|
||||||
|
$"LauncherPid={options.LauncherProcessId}; RequesterPid={options.RequesterProcessId}.");
|
||||||
|
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var lifecycleService = new AirAppLifecycleService(
|
||||||
|
new AirAppProcessStarter(
|
||||||
|
new AirAppHostLocator(),
|
||||||
|
() => options.AppRoot,
|
||||||
|
() => null,
|
||||||
|
() => options.DataRoot));
|
||||||
|
var lifetime = new AirAppRuntimeLifetime(options, lifecycleService);
|
||||||
|
var controlService = new AirAppRuntimeControlService(lifetime);
|
||||||
|
|
||||||
|
using var ipcHost = new AirAppRuntimeIpcHost(lifecycleService, controlService);
|
||||||
|
ipcHost.Start();
|
||||||
|
|
||||||
|
while (lifetime.ShouldKeepAlive())
|
||||||
|
{
|
||||||
|
await Task.Delay(TimeSpan.FromSeconds(2)).ConfigureAwait(false);
|
||||||
|
}
|
||||||
|
|
||||||
|
AirAppRuntimeLogger.Info("Exiting because launcher, host, requester, and AirApp windows are gone.");
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
AirAppRuntimeLogger.Error("Unhandled runtime failure.", ex);
|
||||||
|
return 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,3 @@
|
|||||||
|
using System.Runtime.CompilerServices;
|
||||||
|
|
||||||
|
[assembly: InternalsVisibleTo("LanMountainDesktop.Tests")]
|
||||||
@@ -1,18 +1,11 @@
|
|||||||
using System.Diagnostics;
|
|
||||||
using Avalonia;
|
using Avalonia;
|
||||||
using Avalonia.Controls;
|
using Avalonia.Controls;
|
||||||
using Avalonia.Controls.ApplicationLifetimes;
|
using Avalonia.Controls.ApplicationLifetimes;
|
||||||
using Avalonia.Markup.Xaml;
|
using Avalonia.Markup.Xaml;
|
||||||
using Avalonia.Threading;
|
|
||||||
using LanMountainDesktop.Launcher.Models;
|
using LanMountainDesktop.Launcher.Models;
|
||||||
using LanMountainDesktop.Launcher.Resources;
|
using LanMountainDesktop.Launcher.Shell;
|
||||||
using LanMountainDesktop.Launcher.Services;
|
using LanMountainDesktop.Launcher.Shell.EntryHandlers;
|
||||||
using LanMountainDesktop.Launcher.Services.AirApp;
|
|
||||||
using LanMountainDesktop.Launcher.Services.Ipc;
|
|
||||||
using LanMountainDesktop.Launcher.Views;
|
using LanMountainDesktop.Launcher.Views;
|
||||||
using LanMountainDesktop.Shared.Contracts.Launcher;
|
|
||||||
using LanMountainDesktop.Shared.IPC;
|
|
||||||
using LanMountainDesktop.Shared.IPC.Abstractions.Services;
|
|
||||||
|
|
||||||
namespace LanMountainDesktop.Launcher;
|
namespace LanMountainDesktop.Launcher;
|
||||||
|
|
||||||
@@ -46,804 +39,39 @@ public partial class App : Application
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (ApplicationLifetime is IClassicDesktopStyleApplicationLifetime desktop)
|
if (ApplicationLifetime is not IClassicDesktopStyleApplicationLifetime desktop)
|
||||||
{
|
{
|
||||||
desktop.ShutdownMode = ShutdownMode.OnExplicitShutdown;
|
base.OnFrameworkInitializationCompleted();
|
||||||
|
return;
|
||||||
var context = LauncherRuntimeContext.Current;
|
|
||||||
var execution = LauncherExecutionContext.Capture();
|
|
||||||
Logger.Info(
|
|
||||||
$"Framework initialization completed. Command='{context.Command}'; IsPreview={context.IsPreviewCommand}; " +
|
|
||||||
$"IsDebugMode={context.IsDebugMode}; LaunchSource='{context.LaunchSource}'; " +
|
|
||||||
$"IsElevated={execution.IsElevated}; UserSid='{execution.UserSid ?? string.Empty}'.");
|
|
||||||
|
|
||||||
if (HandlePreviewCommand(context, desktop))
|
|
||||||
{
|
|
||||||
base.OnFrameworkInitializationCompleted();
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (context.IsAirAppBrokerCommand)
|
|
||||||
{
|
|
||||||
_ = RunAirAppBrokerAsync(desktop, context);
|
|
||||||
base.OnFrameworkInitializationCompleted();
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
// 调试模式:只显示 DevDebugWindow,不走正常启动流程
|
|
||||||
// 避免启动主程序后 Launcher 自动退出,导致开发者无法预览 UI
|
|
||||||
if (context.IsDebugMode && !context.IsPreviewCommand &&
|
|
||||||
!string.Equals(context.Command, "apply-update", StringComparison.OrdinalIgnoreCase))
|
|
||||||
{
|
|
||||||
Logger.Info("Debug mode active — showing DevDebugWindow instead of normal launch flow.");
|
|
||||||
var devDebugWindow = new DevDebugWindow();
|
|
||||||
devDebugWindow.Show();
|
|
||||||
base.OnFrameworkInitializationCompleted();
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (string.Equals(context.Command, "apply-update", StringComparison.OrdinalIgnoreCase))
|
|
||||||
{
|
|
||||||
var updateWindow = new UpdateWindow();
|
|
||||||
updateWindow.Show();
|
|
||||||
_ = RunApplyUpdateWithWindowAsync(desktop, context, updateWindow);
|
|
||||||
}
|
|
||||||
else
|
|
||||||
{
|
|
||||||
var splashWindow = CreateSplashWindow();
|
|
||||||
splashWindow.Show();
|
|
||||||
_ = RunCoordinatorWithSplashAsync(desktop, context, splashWindow);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
desktop.ShutdownMode = ShutdownMode.OnExplicitShutdown;
|
||||||
|
|
||||||
|
var context = LauncherRuntimeContext.Current;
|
||||||
|
var execution = LauncherExecutionContext.Capture();
|
||||||
|
Logger.Info(
|
||||||
|
$"Framework initialization completed. Command='{context.Command}'; IsPreview={context.IsPreviewCommand}; " +
|
||||||
|
$"IsDebugMode={context.IsDebugMode}; LaunchSource='{context.LaunchSource}'; " +
|
||||||
|
$"IsElevated={execution.IsElevated}; UserSid='{execution.UserSid ?? string.Empty}'.");
|
||||||
|
|
||||||
|
if (PreviewEntryHandler.TryHandle(context, desktop))
|
||||||
|
{
|
||||||
|
base.OnFrameworkInitializationCompleted();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (context.IsDebugMode && !context.IsPreviewCommand)
|
||||||
|
{
|
||||||
|
Logger.Info("Debug mode active; showing DevDebugWindow instead of normal launch flow.");
|
||||||
|
new DevDebugWindow().Show();
|
||||||
|
base.OnFrameworkInitializationCompleted();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
var splashWindow = LaunchEntryHandler.CreateSplashWindow();
|
||||||
|
splashWindow.Show();
|
||||||
|
_ = LauncherCompositionRoot.RunOrchestratorWithSplashAsync(desktop, context, splashWindow);
|
||||||
|
|
||||||
base.OnFrameworkInitializationCompleted();
|
base.OnFrameworkInitializationCompleted();
|
||||||
}
|
}
|
||||||
|
|
||||||
private static async Task RunAirAppBrokerAsync(
|
|
||||||
IClassicDesktopStyleApplicationLifetime desktop,
|
|
||||||
CommandContext context)
|
|
||||||
{
|
|
||||||
var appRoot = Commands.ResolveAppRoot(context);
|
|
||||||
var requesterPid = context.GetIntOption("requester-pid", 0);
|
|
||||||
var dataLocationResolver = new DataLocationResolver(appRoot);
|
|
||||||
Logger.Info($"Air APP broker starting. AppRoot='{appRoot}'; RequesterPid={requesterPid}.");
|
|
||||||
|
|
||||||
using var airAppIpcHost = new LauncherAirAppLifecycleIpcHost(
|
|
||||||
new LauncherAirAppLifecycleService(
|
|
||||||
new AirAppProcessStarter(
|
|
||||||
new AirAppHostLocator(),
|
|
||||||
() => appRoot,
|
|
||||||
() => null,
|
|
||||||
() => dataLocationResolver.ResolveDataRoot())));
|
|
||||||
airAppIpcHost.Start();
|
|
||||||
|
|
||||||
await WaitForAirAppBrokerExitAsync(requesterPid, airAppIpcHost.LifecycleService).ConfigureAwait(false);
|
|
||||||
|
|
||||||
Logger.Info("Air APP broker exiting.");
|
|
||||||
await Dispatcher.UIThread.InvokeAsync(() => desktop.Shutdown(0), DispatcherPriority.Background);
|
|
||||||
}
|
|
||||||
|
|
||||||
internal static async Task WaitForAirAppBrokerExitAsync(
|
|
||||||
int requesterPid,
|
|
||||||
LauncherAirAppLifecycleService airAppLifecycleService)
|
|
||||||
{
|
|
||||||
while (ShouldKeepAirAppBrokerAlive(requesterPid, airAppLifecycleService))
|
|
||||||
{
|
|
||||||
await Task.Delay(TimeSpan.FromSeconds(2)).ConfigureAwait(false);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
internal static bool ShouldKeepAirAppBrokerAlive(
|
|
||||||
int requesterPid,
|
|
||||||
LauncherAirAppLifecycleService airAppLifecycleService)
|
|
||||||
{
|
|
||||||
return TryGetLiveProcess(requesterPid) || airAppLifecycleService.HasLiveAirApps();
|
|
||||||
}
|
|
||||||
|
|
||||||
private bool HandlePreviewCommand(CommandContext context, IClassicDesktopStyleApplicationLifetime desktop)
|
|
||||||
{
|
|
||||||
switch (context.Command.ToLowerInvariant())
|
|
||||||
{
|
|
||||||
case "preview-splash":
|
|
||||||
{
|
|
||||||
Logger.Info("Preview command: splash.");
|
|
||||||
var splashWindow = CreateSplashWindow();
|
|
||||||
splashWindow.SetDebugMode(true);
|
|
||||||
splashWindow.Show();
|
|
||||||
_ = SimulateSplashPreviewAsync(desktop, splashWindow);
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
case "preview-error":
|
|
||||||
{
|
|
||||||
Logger.Info("Preview command: error.");
|
|
||||||
var errorWindow = new ErrorWindow();
|
|
||||||
errorWindow.SetErrorMessage(Strings.Preview_ErrorMessage);
|
|
||||||
errorWindow.Show();
|
|
||||||
_ = WaitForWindowCloseAsync(desktop, errorWindow);
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
case "preview-multi-instance":
|
|
||||||
{
|
|
||||||
Logger.Info("Preview command: multi-instance prompt.");
|
|
||||||
var promptWindow = new MultiInstancePromptWindow();
|
|
||||||
promptWindow.SetDetails(Environment.ProcessId, "ForegroundDesktop");
|
|
||||||
promptWindow.Show();
|
|
||||||
_ = WaitForWindowCloseAsync(desktop, promptWindow);
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
case "preview-update":
|
|
||||||
{
|
|
||||||
Logger.Info("Preview command: update.");
|
|
||||||
var updateWindow = new UpdateWindow();
|
|
||||||
updateWindow.SetDebugMode(true);
|
|
||||||
updateWindow.Show();
|
|
||||||
_ = SimulateUpdatePreviewAsync(desktop, updateWindow);
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
case "preview-oobe":
|
|
||||||
{
|
|
||||||
Logger.Info("Preview command: oobe.");
|
|
||||||
var oobeWindow = new OobeWindow();
|
|
||||||
oobeWindow.Show();
|
|
||||||
_ = SimulateOobePreviewAsync(desktop, oobeWindow);
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
case "preview-debug":
|
|
||||||
{
|
|
||||||
Logger.Info("Preview command: debug window.");
|
|
||||||
var devDebugWindow = new DevDebugWindow();
|
|
||||||
devDebugWindow.Show();
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
default:
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
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 async Task SimulateSplashPreviewAsync(IClassicDesktopStyleApplicationLifetime desktop, SplashWindow window)
|
|
||||||
{
|
|
||||||
var stages = new[] { "initializing", "update", "plugins", "launch", "ready" };
|
|
||||||
var messages = new[] { Strings.Preview_SplashInitializing, Strings.Preview_SplashCheckingUpdates, Strings.Preview_SplashCheckingPlugins, Strings.Preview_SplashLaunchingHost, Strings.Preview_SplashReady };
|
|
||||||
var reporter = (ISplashStageReporter)window;
|
|
||||||
|
|
||||||
for (var i = 0; i < stages.Length; i++)
|
|
||||||
{
|
|
||||||
reporter.Report(stages[i], messages[i]);
|
|
||||||
await Task.Delay(800).ConfigureAwait(false);
|
|
||||||
}
|
|
||||||
|
|
||||||
await Task.Delay(5000).ConfigureAwait(false);
|
|
||||||
await Dispatcher.UIThread.InvokeAsync(() => desktop.Shutdown(0));
|
|
||||||
}
|
|
||||||
|
|
||||||
private async Task SimulateUpdatePreviewAsync(IClassicDesktopStyleApplicationLifetime desktop, UpdateWindow window)
|
|
||||||
{
|
|
||||||
var stages = new[] { "verify", "extract", "apply", "plugins", "cleanup" };
|
|
||||||
|
|
||||||
for (var i = 0; i < stages.Length; i++)
|
|
||||||
{
|
|
||||||
window.Report(stages[i], string.Format(Strings.Preview_UpdateProcessing, stages[i]), (i + 1) * 20);
|
|
||||||
await Task.Delay(600).ConfigureAwait(false);
|
|
||||||
}
|
|
||||||
|
|
||||||
window.ReportComplete(true, null);
|
|
||||||
await Task.Delay(3000).ConfigureAwait(false);
|
|
||||||
await Dispatcher.UIThread.InvokeAsync(() => desktop.Shutdown(0));
|
|
||||||
}
|
|
||||||
|
|
||||||
private async Task SimulateOobePreviewAsync(IClassicDesktopStyleApplicationLifetime desktop, OobeWindow window)
|
|
||||||
{
|
|
||||||
try
|
|
||||||
{
|
|
||||||
await window.WaitForEnterAsync().ConfigureAwait(false);
|
|
||||||
Logger.Info("OOBE preview completed by user.");
|
|
||||||
}
|
|
||||||
catch (Exception ex)
|
|
||||||
{
|
|
||||||
Logger.Error("OOBE preview failed.", ex);
|
|
||||||
}
|
|
||||||
|
|
||||||
await Dispatcher.UIThread.InvokeAsync(() => desktop.Shutdown(0));
|
|
||||||
}
|
|
||||||
|
|
||||||
private async Task WaitForWindowCloseAsync(IClassicDesktopStyleApplicationLifetime desktop, Window window)
|
|
||||||
{
|
|
||||||
var tcs = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously);
|
|
||||||
window.Closed += (_, _) => tcs.TrySetResult();
|
|
||||||
await tcs.Task.ConfigureAwait(false);
|
|
||||||
await Dispatcher.UIThread.InvokeAsync(() => desktop.Shutdown(0));
|
|
||||||
}
|
|
||||||
|
|
||||||
private static async Task RunCoordinatorWithSplashAsync(
|
|
||||||
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 = LauncherFlowCoordinator.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 deploymentLocator = new DeploymentLocator(appRoot);
|
|
||||||
var coordinator = new LauncherFlowCoordinator(
|
|
||||||
context,
|
|
||||||
deploymentLocator,
|
|
||||||
new OobeStateService(appRoot),
|
|
||||||
new UpdateEngineService(deploymentLocator),
|
|
||||||
new PluginInstallerService(),
|
|
||||||
startupAttemptRegistry,
|
|
||||||
coordinatorServer);
|
|
||||||
|
|
||||||
result = await coordinator.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 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 = LauncherFlowCoordinator.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)
|
|
||||||
{
|
|
||||||
var details = 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
|
|
||||||
};
|
|
||||||
|
|
||||||
return details;
|
|
||||||
}
|
|
||||||
|
|
||||||
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;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private static async Task RunApplyUpdateWithWindowAsync(
|
|
||||||
IClassicDesktopStyleApplicationLifetime desktop,
|
|
||||||
CommandContext context,
|
|
||||||
UpdateWindow window)
|
|
||||||
{
|
|
||||||
var appRoot = Commands.ResolveAppRoot(context);
|
|
||||||
var deploymentLocator = new DeploymentLocator(appRoot);
|
|
||||||
var updateEngine = new UpdateEngineService(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);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
using System.Text.Json;
|
using System.Text.Json;
|
||||||
using System.Text.Json.Serialization;
|
using System.Text.Json.Serialization;
|
||||||
using LanMountainDesktop.Launcher.Models;
|
using LanMountainDesktop.Launcher.Models;
|
||||||
using LanMountainDesktop.Launcher.Services;
|
using LanMountainDesktop.Launcher.Plugins;
|
||||||
using LanMountainDesktop.Shared.Contracts.Launcher;
|
using LanMountainDesktop.Shared.Contracts.Launcher;
|
||||||
using LanMountainDesktop.Shared.IPC.Abstractions.Services;
|
using LanMountainDesktop.Shared.IPC.Abstractions.Services;
|
||||||
|
|
||||||
@@ -11,15 +11,7 @@ namespace LanMountainDesktop.Launcher;
|
|||||||
WriteIndented = true,
|
WriteIndented = true,
|
||||||
PropertyNamingPolicy = JsonKnownNamingPolicy.CamelCase,
|
PropertyNamingPolicy = JsonKnownNamingPolicy.CamelCase,
|
||||||
PropertyNameCaseInsensitive = true)]
|
PropertyNameCaseInsensitive = true)]
|
||||||
[JsonSerializable(typeof(SignedFileMap))]
|
|
||||||
[JsonSerializable(typeof(UpdateFileEntry))]
|
|
||||||
[JsonSerializable(typeof(PlondsUpdateMetadata))]
|
|
||||||
[JsonSerializable(typeof(PlondsFileMap))]
|
|
||||||
[JsonSerializable(typeof(PlondsComponentEntry))]
|
|
||||||
[JsonSerializable(typeof(PlondsFileEntry))]
|
|
||||||
[JsonSerializable(typeof(PlondsHashDescriptor))]
|
|
||||||
[JsonSerializable(typeof(SnapshotMetadata))]
|
[JsonSerializable(typeof(SnapshotMetadata))]
|
||||||
[JsonSerializable(typeof(InstallCheckpoint))]
|
|
||||||
[JsonSerializable(typeof(AppVersionInfo))]
|
[JsonSerializable(typeof(AppVersionInfo))]
|
||||||
[JsonSerializable(typeof(StartupProgressMessage))]
|
[JsonSerializable(typeof(StartupProgressMessage))]
|
||||||
[JsonSerializable(typeof(LauncherCoordinatorRequest))]
|
[JsonSerializable(typeof(LauncherCoordinatorRequest))]
|
||||||
@@ -30,18 +22,18 @@ namespace LanMountainDesktop.Launcher;
|
|||||||
[JsonSerializable(typeof(PublicTaskbarStatus))]
|
[JsonSerializable(typeof(PublicTaskbarStatus))]
|
||||||
[JsonSerializable(typeof(PublicShellActivationResult))]
|
[JsonSerializable(typeof(PublicShellActivationResult))]
|
||||||
[JsonSerializable(typeof(LauncherResult))]
|
[JsonSerializable(typeof(LauncherResult))]
|
||||||
[JsonSerializable(typeof(HostDiscoveryConfig))]
|
|
||||||
[JsonSerializable(typeof(PluginManifest))]
|
[JsonSerializable(typeof(PluginManifest))]
|
||||||
[JsonSerializable(typeof(PendingUpgrade))]
|
|
||||||
[JsonSerializable(typeof(List<PendingUpgrade>))]
|
[JsonSerializable(typeof(List<PendingUpgrade>))]
|
||||||
[JsonSerializable(typeof(OobeStateFile))]
|
[JsonSerializable(typeof(OobeStateFile))]
|
||||||
[JsonSerializable(typeof(DataLocationConfig))]
|
[JsonSerializable(typeof(DataLocationConfig))]
|
||||||
[JsonSerializable(typeof(GitHubRelease))]
|
|
||||||
[JsonSerializable(typeof(GitHubAsset))]
|
|
||||||
[JsonSerializable(typeof(List<GitHubRelease>))]
|
|
||||||
[JsonSerializable(typeof(StartupAttemptRecord))]
|
[JsonSerializable(typeof(StartupAttemptRecord))]
|
||||||
[JsonSerializable(typeof(PrivacyConfig))]
|
[JsonSerializable(typeof(PrivacyConfig))]
|
||||||
[JsonSerializable(typeof(PrivacyAgreementState))]
|
[JsonSerializable(typeof(PrivacyAgreementState))]
|
||||||
[JsonSerializable(typeof(LanMountainDesktop.Shared.Contracts.Update.InstallProgressReport))]
|
[JsonSerializable(typeof(AirAppOpenRequest))]
|
||||||
[JsonSerializable(typeof(LanMountainDesktop.Shared.Contracts.Update.InstallCompleteReport))]
|
[JsonSerializable(typeof(AirAppRegistrationRequest))]
|
||||||
|
[JsonSerializable(typeof(AirAppInstanceInfo))]
|
||||||
|
[JsonSerializable(typeof(AirAppOperationResult))]
|
||||||
|
[JsonSerializable(typeof(AirAppInstanceInfo[]))]
|
||||||
|
[JsonSerializable(typeof(AirAppRuntimeControlResult))]
|
||||||
|
[JsonSerializable(typeof(AirAppRuntimeStatus))]
|
||||||
internal sealed partial class AppJsonContext : JsonSerializerContext;
|
internal sealed partial class AppJsonContext : JsonSerializerContext;
|
||||||
|
|||||||
@@ -4,15 +4,11 @@ namespace LanMountainDesktop.Launcher;
|
|||||||
|
|
||||||
internal sealed class CommandContext
|
internal sealed class CommandContext
|
||||||
{
|
{
|
||||||
public const string AirAppBrokerCommand = "air-app-broker";
|
|
||||||
|
|
||||||
private const string LaunchSourceOptionName = "launch-source";
|
private const string LaunchSourceOptionName = "launch-source";
|
||||||
|
|
||||||
private static readonly string[] GuiCommands =
|
private static readonly string[] GuiCommands =
|
||||||
[
|
[
|
||||||
"launch",
|
"launch",
|
||||||
AirAppBrokerCommand,
|
|
||||||
"apply-update",
|
|
||||||
"preview-splash",
|
"preview-splash",
|
||||||
"preview-error",
|
"preview-error",
|
||||||
"preview-update",
|
"preview-update",
|
||||||
@@ -63,16 +59,11 @@ internal sealed class CommandContext
|
|||||||
public bool IsPreviewCommand =>
|
public bool IsPreviewCommand =>
|
||||||
Command.StartsWith("preview-", StringComparison.OrdinalIgnoreCase);
|
Command.StartsWith("preview-", StringComparison.OrdinalIgnoreCase);
|
||||||
|
|
||||||
public bool IsAirAppBrokerCommand =>
|
|
||||||
string.Equals(Command, AirAppBrokerCommand, StringComparison.OrdinalIgnoreCase);
|
|
||||||
|
|
||||||
public bool IsGuiCommand =>
|
public bool IsGuiCommand =>
|
||||||
GuiCommands.Contains(Command, StringComparer.OrdinalIgnoreCase);
|
GuiCommands.Contains(Command, StringComparer.OrdinalIgnoreCase);
|
||||||
|
|
||||||
public bool IsMaintenanceCommand =>
|
public bool IsMaintenanceCommand =>
|
||||||
string.Equals(LaunchSource, "apply-update", StringComparison.OrdinalIgnoreCase) ||
|
|
||||||
string.Equals(LaunchSource, "plugin-install", StringComparison.OrdinalIgnoreCase) ||
|
string.Equals(LaunchSource, "plugin-install", StringComparison.OrdinalIgnoreCase) ||
|
||||||
string.Equals(Command, "update", StringComparison.OrdinalIgnoreCase) ||
|
|
||||||
string.Equals(Command, "plugin", StringComparison.OrdinalIgnoreCase);
|
string.Equals(Command, "plugin", StringComparison.OrdinalIgnoreCase);
|
||||||
|
|
||||||
public string? ExplicitAppRoot => GetOption("app-root");
|
public string? ExplicitAppRoot => GetOption("app-root");
|
||||||
@@ -118,11 +109,6 @@ internal sealed class CommandContext
|
|||||||
return "debug-preview";
|
return "debug-preview";
|
||||||
}
|
}
|
||||||
|
|
||||||
if (string.Equals(Command, "apply-update", StringComparison.OrdinalIgnoreCase))
|
|
||||||
{
|
|
||||||
return "apply-update";
|
|
||||||
}
|
|
||||||
|
|
||||||
if (IsLegacyPluginInstall || string.Equals(Command, "plugin", StringComparison.OrdinalIgnoreCase))
|
if (IsLegacyPluginInstall || string.Equals(Command, "plugin", StringComparison.OrdinalIgnoreCase))
|
||||||
{
|
{
|
||||||
return "plugin-install";
|
return "plugin-install";
|
||||||
@@ -143,7 +129,6 @@ internal sealed class CommandContext
|
|||||||
"normal" => "normal",
|
"normal" => "normal",
|
||||||
"restart" => "restart",
|
"restart" => "restart",
|
||||||
"postinstall" => "postinstall",
|
"postinstall" => "postinstall",
|
||||||
"apply-update" => "apply-update",
|
|
||||||
"plugin-install" => "plugin-install",
|
"plugin-install" => "plugin-install",
|
||||||
"debug-preview" => "debug-preview",
|
"debug-preview" => "debug-preview",
|
||||||
_ => null
|
_ => null
|
||||||
|
|||||||
@@ -3,7 +3,7 @@ using System.Text.Json;
|
|||||||
using LanMountainDesktop.Launcher.Models;
|
using LanMountainDesktop.Launcher.Models;
|
||||||
using LanMountainDesktop.Shared.Contracts.Launcher;
|
using LanMountainDesktop.Shared.Contracts.Launcher;
|
||||||
|
|
||||||
namespace LanMountainDesktop.Launcher.Services;
|
namespace LanMountainDesktop.Launcher.Deployment;
|
||||||
|
|
||||||
internal sealed class DeploymentLocator
|
internal sealed class DeploymentLocator
|
||||||
{
|
{
|
||||||
@@ -1,4 +1,4 @@
|
|||||||
namespace LanMountainDesktop.Launcher.Services;
|
namespace LanMountainDesktop.Launcher.Deployment;
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// 主程序发现选项
|
/// 主程序发现选项
|
||||||
@@ -1,6 +1,6 @@
|
|||||||
using LanMountainDesktop.Shared.Contracts.Launcher;
|
using LanMountainDesktop.Shared.Contracts.Launcher;
|
||||||
|
|
||||||
namespace LanMountainDesktop.Launcher.Services;
|
namespace LanMountainDesktop.Launcher.Deployment;
|
||||||
|
|
||||||
internal sealed record HostLaunchPlan(
|
internal sealed record HostLaunchPlan(
|
||||||
string HostPath,
|
string HostPath,
|
||||||
@@ -1,4 +1,4 @@
|
|||||||
namespace LanMountainDesktop.Launcher.Services;
|
namespace LanMountainDesktop.Launcher.Deployment;
|
||||||
|
|
||||||
internal sealed class HostResolutionResult
|
internal sealed class HostResolutionResult
|
||||||
{
|
{
|
||||||
@@ -1,7 +1,7 @@
|
|||||||
using System.Diagnostics;
|
using System.Diagnostics;
|
||||||
using Microsoft.Win32;
|
using Microsoft.Win32;
|
||||||
|
|
||||||
namespace LanMountainDesktop.Launcher.Services;
|
namespace LanMountainDesktop.Launcher.Deployment;
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// 老版本检测器 - 检测 0.8.x 及更早的单应用模式安装
|
/// 老版本检测器 - 检测 0.8.x 及更早的单应用模式安装
|
||||||
6
LanMountainDesktop.Launcher/GlobalUsings.cs
Normal file
6
LanMountainDesktop.Launcher/GlobalUsings.cs
Normal file
@@ -0,0 +1,6 @@
|
|||||||
|
global using LanMountainDesktop.Launcher.Deployment;
|
||||||
|
global using LanMountainDesktop.Launcher.Infrastructure;
|
||||||
|
global using LanMountainDesktop.Launcher.Ipc;
|
||||||
|
global using LanMountainDesktop.Launcher.Oobe;
|
||||||
|
global using LanMountainDesktop.Launcher.Plugins;
|
||||||
|
global using LanMountainDesktop.Launcher.Startup;
|
||||||
@@ -2,7 +2,7 @@ using System.Text;
|
|||||||
using System.Text.Json;
|
using System.Text.Json;
|
||||||
using LanMountainDesktop.Launcher.Models;
|
using LanMountainDesktop.Launcher.Models;
|
||||||
|
|
||||||
namespace LanMountainDesktop.Launcher.Services;
|
namespace LanMountainDesktop.Launcher.Infrastructure;
|
||||||
|
|
||||||
internal static class Commands
|
internal static class Commands
|
||||||
{
|
{
|
||||||
@@ -14,7 +14,7 @@ internal static class Commands
|
|||||||
{
|
{
|
||||||
var source = context.GetOption("source") ?? string.Empty;
|
var source = context.GetOption("source") ?? string.Empty;
|
||||||
var pluginsDir = context.GetOption("plugins-dir") ?? string.Empty;
|
var pluginsDir = context.GetOption("plugins-dir") ?? string.Empty;
|
||||||
result = installer.InstallPackage(source, pluginsDir);
|
result = installer.InstallPackage(source, pluginsDir, context.ExplicitAppRoot);
|
||||||
}
|
}
|
||||||
catch (Exception ex)
|
catch (Exception ex)
|
||||||
{
|
{
|
||||||
@@ -35,15 +35,14 @@ internal static class Commands
|
|||||||
public static async Task<int> RunCliCommandAsync(CommandContext context)
|
public static async Task<int> RunCliCommandAsync(CommandContext context)
|
||||||
{
|
{
|
||||||
var appRoot = ResolveAppRoot(context);
|
var appRoot = ResolveAppRoot(context);
|
||||||
var deploymentLocator = new DeploymentLocator(appRoot);
|
_ = new DeploymentLocator(appRoot);
|
||||||
var updateEngine = new UpdateEngineService(deploymentLocator);
|
|
||||||
var pluginInstaller = new PluginInstallerService();
|
var pluginInstaller = new PluginInstallerService();
|
||||||
var pluginUpgrades = new PluginUpgradeQueueService(pluginInstaller);
|
var pluginUpgrades = new PluginUpgradeQueueService(pluginInstaller);
|
||||||
|
|
||||||
LauncherResult result;
|
LauncherResult result;
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
result = await ExecuteCoreAsync(context, updateEngine, pluginInstaller, pluginUpgrades).ConfigureAwait(false);
|
result = ExecuteCore(context, pluginInstaller, pluginUpgrades);
|
||||||
}
|
}
|
||||||
catch (Exception ex)
|
catch (Exception ex)
|
||||||
{
|
{
|
||||||
@@ -61,16 +60,13 @@ internal static class Commands
|
|||||||
return result.Success ? 0 : 1;
|
return result.Success ? 0 : 1;
|
||||||
}
|
}
|
||||||
|
|
||||||
private static async Task<LauncherResult> ExecuteCoreAsync(
|
private static LauncherResult ExecuteCore(
|
||||||
CommandContext context,
|
CommandContext context,
|
||||||
UpdateEngineService updateEngine,
|
|
||||||
PluginInstallerService pluginInstaller,
|
PluginInstallerService pluginInstaller,
|
||||||
PluginUpgradeQueueService pluginUpgrades)
|
PluginUpgradeQueueService pluginUpgrades)
|
||||||
{
|
{
|
||||||
switch (context.Command.ToLowerInvariant())
|
switch (context.Command.ToLowerInvariant())
|
||||||
{
|
{
|
||||||
case "update":
|
|
||||||
return await ExecuteUpdateAsync(context, updateEngine).ConfigureAwait(false);
|
|
||||||
case "plugin":
|
case "plugin":
|
||||||
return ExecutePluginCommand(context, pluginInstaller, pluginUpgrades);
|
return ExecutePluginCommand(context, pluginInstaller, pluginUpgrades);
|
||||||
default:
|
default:
|
||||||
@@ -84,33 +80,6 @@ internal static class Commands
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private static async Task<LauncherResult> ExecuteUpdateAsync(CommandContext context, UpdateEngineService updateEngine)
|
|
||||||
{
|
|
||||||
return context.SubCommand.ToLowerInvariant() switch
|
|
||||||
{
|
|
||||||
"check" => updateEngine.CheckPendingUpdate(),
|
|
||||||
"apply" => await updateEngine.ApplyPendingUpdateAsync().ConfigureAwait(false),
|
|
||||||
"rollback" => updateEngine.RollbackLatest(),
|
|
||||||
"download" => await DownloadUpdatePayloadAsync(context, updateEngine).ConfigureAwait(false),
|
|
||||||
_ => new LauncherResult
|
|
||||||
{
|
|
||||||
Success = false,
|
|
||||||
Stage = "update",
|
|
||||||
Code = "unsupported_subcommand",
|
|
||||||
Message = $"Unsupported update sub-command '{context.SubCommand}'."
|
|
||||||
}
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
private static async Task<LauncherResult> DownloadUpdatePayloadAsync(CommandContext context, UpdateEngineService updateEngine)
|
|
||||||
{
|
|
||||||
return await updateEngine.DownloadAsync(
|
|
||||||
context.GetOption("manifest-url") ?? throw new InvalidOperationException("Missing --manifest-url."),
|
|
||||||
context.GetOption("signature-url") ?? throw new InvalidOperationException("Missing --signature-url."),
|
|
||||||
context.GetOption("archive-url") ?? throw new InvalidOperationException("Missing --archive-url."),
|
|
||||||
CancellationToken.None).ConfigureAwait(false);
|
|
||||||
}
|
|
||||||
|
|
||||||
private static LauncherResult ExecutePluginCommand(
|
private static LauncherResult ExecutePluginCommand(
|
||||||
CommandContext context,
|
CommandContext context,
|
||||||
PluginInstallerService pluginInstaller,
|
PluginInstallerService pluginInstaller,
|
||||||
@@ -122,12 +91,12 @@ internal static class Commands
|
|||||||
{
|
{
|
||||||
var source = context.GetOption("source") ?? throw new InvalidOperationException("Missing --source.");
|
var source = context.GetOption("source") ?? throw new InvalidOperationException("Missing --source.");
|
||||||
var pluginsDir = context.GetOption("plugins-dir") ?? throw new InvalidOperationException("Missing --plugins-dir.");
|
var pluginsDir = context.GetOption("plugins-dir") ?? throw new InvalidOperationException("Missing --plugins-dir.");
|
||||||
return pluginInstaller.InstallPackage(source, pluginsDir);
|
return pluginInstaller.InstallPackage(source, pluginsDir, context.ExplicitAppRoot);
|
||||||
}
|
}
|
||||||
case "update":
|
case "update":
|
||||||
{
|
{
|
||||||
var pluginsDir = context.GetOption("plugins-dir") ?? throw new InvalidOperationException("Missing --plugins-dir.");
|
var pluginsDir = context.GetOption("plugins-dir") ?? throw new InvalidOperationException("Missing --plugins-dir.");
|
||||||
return pluginUpgrades.ApplyPendingUpgrades(pluginsDir);
|
return pluginUpgrades.ApplyPendingUpgrades(pluginsDir, context.ExplicitAppRoot);
|
||||||
}
|
}
|
||||||
default:
|
default:
|
||||||
return new LauncherResult
|
return new LauncherResult
|
||||||
@@ -171,16 +140,12 @@ internal static class Commands
|
|||||||
? launcherDir
|
? launcherDir
|
||||||
: AppContext.BaseDirectory);
|
: AppContext.BaseDirectory);
|
||||||
|
|
||||||
// 发布版结构:Launcher 和 app-* 目录在同一目录
|
|
||||||
// 检查当前目录是否有 app-* 子目录(发布版)
|
|
||||||
var appDirs = Directory.GetDirectories(baseDir, "app-*", SearchOption.TopDirectoryOnly);
|
var appDirs = Directory.GetDirectories(baseDir, "app-*", SearchOption.TopDirectoryOnly);
|
||||||
if (appDirs.Length > 0)
|
if (appDirs.Length > 0)
|
||||||
{
|
{
|
||||||
// 找到 app-* 目录,说明是发布版结构
|
|
||||||
return baseDir;
|
return baseDir;
|
||||||
}
|
}
|
||||||
|
|
||||||
// 开发环境:检查父目录是否有主程序
|
|
||||||
var parent = Path.GetFullPath(Path.Combine(baseDir, ".."));
|
var parent = Path.GetFullPath(Path.Combine(baseDir, ".."));
|
||||||
var parentHost = OperatingSystem.IsWindows()
|
var parentHost = OperatingSystem.IsWindows()
|
||||||
? Path.Combine(parent, "LanMountainDesktop.exe")
|
? Path.Combine(parent, "LanMountainDesktop.exe")
|
||||||
@@ -190,7 +155,6 @@ internal static class Commands
|
|||||||
return parent;
|
return parent;
|
||||||
}
|
}
|
||||||
|
|
||||||
// 默认返回 baseDir
|
|
||||||
return baseDir;
|
return baseDir;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -1,7 +1,7 @@
|
|||||||
using System.Text.Json;
|
using System.Text.Json;
|
||||||
using LanMountainDesktop.Launcher.Models;
|
using LanMountainDesktop.Launcher.Models;
|
||||||
|
|
||||||
namespace LanMountainDesktop.Launcher.Services;
|
namespace LanMountainDesktop.Launcher.Infrastructure;
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// 解析应用数据目录位置。
|
/// 解析应用数据目录位置。
|
||||||
@@ -193,8 +193,10 @@ internal sealed class DataLocationResolver
|
|||||||
|
|
||||||
public bool ApplyLocationChoice(DataLocationMode mode, string? customPath = null, bool migrateExistingData = false)
|
public bool ApplyLocationChoice(DataLocationMode mode, string? customPath = null, bool migrateExistingData = false)
|
||||||
{
|
{
|
||||||
var targetDataRoot = mode == DataLocationMode.Portable && !string.IsNullOrWhiteSpace(customPath)
|
var targetDataRoot = mode == DataLocationMode.Portable
|
||||||
? Path.GetFullPath(customPath)
|
? Path.GetFullPath(!string.IsNullOrWhiteSpace(customPath)
|
||||||
|
? customPath
|
||||||
|
: DefaultPortableDataPath)
|
||||||
: _defaultSystemDataPath;
|
: _defaultSystemDataPath;
|
||||||
|
|
||||||
var config = new DataLocationConfig
|
var config = new DataLocationConfig
|
||||||
@@ -2,7 +2,7 @@ using System.Diagnostics;
|
|||||||
using System.Runtime.InteropServices;
|
using System.Runtime.InteropServices;
|
||||||
using Microsoft.Win32;
|
using Microsoft.Win32;
|
||||||
|
|
||||||
namespace LanMountainDesktop.Launcher.Services;
|
namespace LanMountainDesktop.Launcher.Infrastructure;
|
||||||
|
|
||||||
internal enum DotNetRuntimeArchitecture
|
internal enum DotNetRuntimeArchitecture
|
||||||
{
|
{
|
||||||
@@ -1,4 +1,4 @@
|
|||||||
namespace LanMountainDesktop.Launcher.Services;
|
namespace LanMountainDesktop.Launcher.Infrastructure;
|
||||||
|
|
||||||
internal interface ISplashStageReporter
|
internal interface ISplashStageReporter
|
||||||
{
|
{
|
||||||
@@ -1,7 +1,7 @@
|
|||||||
using System.Globalization;
|
using System.Globalization;
|
||||||
using System.Text.Json.Nodes;
|
using System.Text.Json.Nodes;
|
||||||
|
|
||||||
namespace LanMountainDesktop.Launcher.Services;
|
namespace LanMountainDesktop.Launcher.Infrastructure;
|
||||||
|
|
||||||
internal static class LanguagePreferenceService
|
internal static class LanguagePreferenceService
|
||||||
{
|
{
|
||||||
@@ -1,4 +1,4 @@
|
|||||||
namespace LanMountainDesktop.Launcher.Services;
|
namespace LanMountainDesktop.Launcher.Infrastructure;
|
||||||
|
|
||||||
internal sealed record LauncherDebugSettings(bool DevModeEnabled, string? CustomHostPath);
|
internal sealed record LauncherDebugSettings(bool DevModeEnabled, string? CustomHostPath);
|
||||||
|
|
||||||
@@ -1,7 +1,7 @@
|
|||||||
using System.Security.Principal;
|
using System.Security.Principal;
|
||||||
using LanMountainDesktop.Launcher.Models;
|
using LanMountainDesktop.Launcher.Models;
|
||||||
|
|
||||||
namespace LanMountainDesktop.Launcher.Services;
|
namespace LanMountainDesktop.Launcher.Infrastructure;
|
||||||
|
|
||||||
internal static class LauncherExecutionContext
|
internal static class LauncherExecutionContext
|
||||||
{
|
{
|
||||||
@@ -1,6 +1,6 @@
|
|||||||
using System.Text;
|
using System.Text;
|
||||||
|
|
||||||
namespace LanMountainDesktop.Launcher.Services;
|
namespace LanMountainDesktop.Launcher.Infrastructure;
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// 简单的日志记录器 - 同时输出到控制台和文件
|
/// 简单的日志记录器 - 同时输出到控制台和文件
|
||||||
@@ -3,7 +3,7 @@ using System.Text;
|
|||||||
using System.Text.Json;
|
using System.Text.Json;
|
||||||
using LanMountainDesktop.Launcher.Models;
|
using LanMountainDesktop.Launcher.Models;
|
||||||
|
|
||||||
namespace LanMountainDesktop.Launcher.Services.Ipc;
|
namespace LanMountainDesktop.Launcher.Ipc;
|
||||||
|
|
||||||
internal sealed class LauncherCoordinatorIpcClient
|
internal sealed class LauncherCoordinatorIpcClient
|
||||||
{
|
{
|
||||||
@@ -4,7 +4,7 @@ using System.Text.Json;
|
|||||||
using System.IO.Pipes;
|
using System.IO.Pipes;
|
||||||
using LanMountainDesktop.Launcher.Models;
|
using LanMountainDesktop.Launcher.Models;
|
||||||
|
|
||||||
namespace LanMountainDesktop.Launcher.Services.Ipc;
|
namespace LanMountainDesktop.Launcher.Ipc;
|
||||||
|
|
||||||
internal sealed class LauncherCoordinatorIpcServer : IDisposable
|
internal sealed class LauncherCoordinatorIpcServer : IDisposable
|
||||||
{
|
{
|
||||||
@@ -52,6 +52,7 @@
|
|||||||
</ItemGroup>
|
</ItemGroup>
|
||||||
|
|
||||||
<!-- AOT 兼容性:某些包可能需要特殊处理 -->
|
<!-- AOT 兼容性:某些包可能需要特殊处理 -->
|
||||||
|
|
||||||
<PropertyGroup Condition="'$(PublishAot)' == 'true'">
|
<PropertyGroup Condition="'$(PublishAot)' == 'true'">
|
||||||
<!-- 忽略某些警告 -->
|
<!-- 忽略某些警告 -->
|
||||||
<SuppressTrimAnalysisWarnings>false</SuppressTrimAnalysisWarnings>
|
<SuppressTrimAnalysisWarnings>false</SuppressTrimAnalysisWarnings>
|
||||||
@@ -60,7 +61,8 @@
|
|||||||
|
|
||||||
<!-- AOT 模式下禁用反射式 JSON 序列化,强制使用 Source Generator -->
|
<!-- AOT 模式下禁用反射式 JSON 序列化,强制使用 Source Generator -->
|
||||||
<!-- 之前设置为 true 与 AOT 矛盾,导致 IL2026/IL3050 警告和运行时失败 -->
|
<!-- 之前设置为 true 与 AOT 矛盾,导致 IL2026/IL3050 警告和运行时失败 -->
|
||||||
<JsonSerializerIsReflectionEnabledByDefault>false</JsonSerializerIsReflectionEnabledByDefault>
|
<!-- [Fix]: 必须设置为 true 以支持 dotnetCampus.Ipc 内部的反射序列化。相关类型的剪裁保护通过 AppJsonContext 保证 -->
|
||||||
|
<JsonSerializerIsReflectionEnabledByDefault>true</JsonSerializerIsReflectionEnabledByDefault>
|
||||||
|
|
||||||
<!-- 启用 ISerializable 支持(部分库需要) -->
|
<!-- 启用 ISerializable 支持(部分库需要) -->
|
||||||
<IsAotCompatible>true</IsAotCompatible>
|
<IsAotCompatible>true</IsAotCompatible>
|
||||||
|
|||||||
@@ -18,6 +18,7 @@
|
|||||||
<ItemGroup>
|
<ItemGroup>
|
||||||
<!-- 只引用 Shared.Contracts(IPC 协议) -->
|
<!-- 只引用 Shared.Contracts(IPC 协议) -->
|
||||||
<ProjectReference Include="..\LanMountainDesktop.Shared.Contracts\LanMountainDesktop.Shared.Contracts.csproj" />
|
<ProjectReference Include="..\LanMountainDesktop.Shared.Contracts\LanMountainDesktop.Shared.Contracts.csproj" />
|
||||||
|
<ProjectReference Include="..\LanMountainDesktop.PluginPackaging\LanMountainDesktop.PluginPackaging.csproj" />
|
||||||
<ProjectReference Include="..\LanMountainDesktop.Shared.IPC\LanMountainDesktop.Shared.IPC.csproj" />
|
<ProjectReference Include="..\LanMountainDesktop.Shared.IPC\LanMountainDesktop.Shared.IPC.csproj" />
|
||||||
</ItemGroup>
|
</ItemGroup>
|
||||||
|
|
||||||
@@ -29,6 +30,7 @@
|
|||||||
<PackageReference Include="Avalonia.Fonts.Inter" />
|
<PackageReference Include="Avalonia.Fonts.Inter" />
|
||||||
<PackageReference Include="ClassIsland.Markdown.Avalonia" />
|
<PackageReference Include="ClassIsland.Markdown.Avalonia" />
|
||||||
<PackageReference Include="CommunityToolkit.Mvvm" />
|
<PackageReference Include="CommunityToolkit.Mvvm" />
|
||||||
|
<PackageReference Include="Microsoft.Extensions.DependencyInjection" />
|
||||||
<PackageReference Include="Tmds.DBus.Protocol" />
|
<PackageReference Include="Tmds.DBus.Protocol" />
|
||||||
</ItemGroup>
|
</ItemGroup>
|
||||||
|
|
||||||
@@ -44,7 +46,7 @@
|
|||||||
<Target Name="CopyPublicKeyToLauncherDir" AfterTargets="Build">
|
<Target Name="CopyPublicKeyToLauncherDir" AfterTargets="Build">
|
||||||
<PropertyGroup>
|
<PropertyGroup>
|
||||||
<PublicKeySource>$(MSBuildProjectDirectory)\Assets\public-key.pem</PublicKeySource>
|
<PublicKeySource>$(MSBuildProjectDirectory)\Assets\public-key.pem</PublicKeySource>
|
||||||
<PublicKeyDestDir>$(OutDir).launcher\update</PublicKeyDestDir>
|
<PublicKeyDestDir>$(OutDir).Launcher\update</PublicKeyDestDir>
|
||||||
</PropertyGroup>
|
</PropertyGroup>
|
||||||
<MakeDir Directories="$(PublicKeyDestDir)" />
|
<MakeDir Directories="$(PublicKeyDestDir)" />
|
||||||
<Copy SourceFiles="$(PublicKeySource)" DestinationFolder="$(PublicKeyDestDir)" SkipUnchangedFiles="true" />
|
<Copy SourceFiles="$(PublicKeySource)" DestinationFolder="$(PublicKeyDestDir)" SkipUnchangedFiles="true" />
|
||||||
@@ -53,7 +55,7 @@
|
|||||||
<Target Name="CopyPublicKeyToPublishDir" AfterTargets="Publish">
|
<Target Name="CopyPublicKeyToPublishDir" AfterTargets="Publish">
|
||||||
<PropertyGroup>
|
<PropertyGroup>
|
||||||
<PublishedKeySource>$(MSBuildProjectDirectory)\Assets\public-key.pem</PublishedKeySource>
|
<PublishedKeySource>$(MSBuildProjectDirectory)\Assets\public-key.pem</PublishedKeySource>
|
||||||
<PublishedKeyDestDir>$(PublishDir).launcher\update</PublishedKeyDestDir>
|
<PublishedKeyDestDir>$(PublishDir).Launcher\update</PublishedKeyDestDir>
|
||||||
</PropertyGroup>
|
</PropertyGroup>
|
||||||
<MakeDir Directories="$(PublishedKeyDestDir)" />
|
<MakeDir Directories="$(PublishedKeyDestDir)" />
|
||||||
<Copy SourceFiles="$(PublishedKeySource)" DestinationFolder="$(PublishedKeyDestDir)" SkipUnchangedFiles="true" />
|
<Copy SourceFiles="$(PublishedKeySource)" DestinationFolder="$(PublishedKeyDestDir)" SkipUnchangedFiles="true" />
|
||||||
|
|||||||
@@ -1,17 +0,0 @@
|
|||||||
namespace LanMountainDesktop.Launcher.Models;
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// 更新频道
|
|
||||||
/// </summary>
|
|
||||||
public enum UpdateChannel
|
|
||||||
{
|
|
||||||
/// <summary>
|
|
||||||
/// 正式版 - 只检查 prerelease=false 的版本
|
|
||||||
/// </summary>
|
|
||||||
Stable,
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// 预览版 - 检查所有版本(包括 prerelease=true)
|
|
||||||
/// </summary>
|
|
||||||
Preview
|
|
||||||
}
|
|
||||||
@@ -1,13 +0,0 @@
|
|||||||
namespace LanMountainDesktop.Launcher.Models;
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// 更新检查结果
|
|
||||||
/// </summary>
|
|
||||||
public sealed class UpdateCheckResult
|
|
||||||
{
|
|
||||||
public bool HasUpdate { get; init; }
|
|
||||||
public string? LatestVersion { get; init; }
|
|
||||||
public string? CurrentVersion { get; init; }
|
|
||||||
public ReleaseInfo? Release { get; init; }
|
|
||||||
public string? ErrorMessage { get; init; }
|
|
||||||
}
|
|
||||||
@@ -1,29 +1,5 @@
|
|||||||
namespace LanMountainDesktop.Launcher.Models;
|
namespace LanMountainDesktop.Launcher.Models;
|
||||||
|
|
||||||
internal sealed class SignedFileMap
|
|
||||||
{
|
|
||||||
public string? FromVersion { get; set; }
|
|
||||||
|
|
||||||
public string? ToVersion { get; set; }
|
|
||||||
|
|
||||||
public string? Platform { get; set; }
|
|
||||||
|
|
||||||
public string? Arch { get; set; }
|
|
||||||
|
|
||||||
public List<UpdateFileEntry> Files { get; set; } = [];
|
|
||||||
}
|
|
||||||
|
|
||||||
internal sealed class UpdateFileEntry
|
|
||||||
{
|
|
||||||
public string Path { get; set; } = string.Empty;
|
|
||||||
|
|
||||||
public string? ArchivePath { get; set; }
|
|
||||||
|
|
||||||
public string Action { get; set; } = "replace";
|
|
||||||
|
|
||||||
public string? Sha256 { get; set; }
|
|
||||||
}
|
|
||||||
|
|
||||||
internal sealed class SnapshotMetadata
|
internal sealed class SnapshotMetadata
|
||||||
{
|
{
|
||||||
public string SnapshotId { get; set; } = string.Empty;
|
public string SnapshotId { get; set; } = string.Empty;
|
||||||
@@ -40,124 +16,3 @@ internal sealed class SnapshotMetadata
|
|||||||
|
|
||||||
public string Status { get; set; } = "pending";
|
public string Status { get; set; } = "pending";
|
||||||
}
|
}
|
||||||
|
|
||||||
internal sealed class InstallCheckpoint
|
|
||||||
{
|
|
||||||
public string SnapshotId { get; set; } = string.Empty;
|
|
||||||
|
|
||||||
public string SourceVersion { get; set; } = string.Empty;
|
|
||||||
|
|
||||||
public string? TargetVersion { get; set; }
|
|
||||||
|
|
||||||
public string? SourceDirectory { get; set; }
|
|
||||||
|
|
||||||
public string TargetDirectory { get; set; } = string.Empty;
|
|
||||||
|
|
||||||
public bool IsInitialDeployment { get; set; }
|
|
||||||
|
|
||||||
public int AppliedCount { get; set; }
|
|
||||||
|
|
||||||
public int VerifiedCount { get; set; }
|
|
||||||
}
|
|
||||||
|
|
||||||
internal sealed class UpdateApplyResult
|
|
||||||
{
|
|
||||||
public bool Success { get; init; }
|
|
||||||
|
|
||||||
public string Message { get; init; } = string.Empty;
|
|
||||||
|
|
||||||
public string? FromVersion { get; init; }
|
|
||||||
|
|
||||||
public string? ToVersion { get; init; }
|
|
||||||
|
|
||||||
public string? RolledBackTo { get; init; }
|
|
||||||
}
|
|
||||||
|
|
||||||
internal sealed class PlondsUpdateMetadata
|
|
||||||
{
|
|
||||||
public string? DistributionId { get; set; }
|
|
||||||
|
|
||||||
public string? Channel { get; set; }
|
|
||||||
|
|
||||||
public string? SubChannel { get; set; }
|
|
||||||
|
|
||||||
public string? FromVersion { get; set; }
|
|
||||||
|
|
||||||
public string? ToVersion { get; set; }
|
|
||||||
|
|
||||||
public string? FileMapPath { get; set; }
|
|
||||||
|
|
||||||
public string? FileMapSignaturePath { get; set; }
|
|
||||||
|
|
||||||
public Dictionary<string, string> Metadata { get; set; } = [];
|
|
||||||
}
|
|
||||||
|
|
||||||
internal sealed class PlondsFileMap
|
|
||||||
{
|
|
||||||
public string? DistributionId { get; set; }
|
|
||||||
|
|
||||||
public string? FromVersion { get; set; }
|
|
||||||
|
|
||||||
public string? ToVersion { get; set; }
|
|
||||||
|
|
||||||
public string? Version { get; set; }
|
|
||||||
|
|
||||||
public string? Platform { get; set; }
|
|
||||||
|
|
||||||
public string? Arch { get; set; }
|
|
||||||
|
|
||||||
public Dictionary<string, string> Metadata { get; set; } = [];
|
|
||||||
|
|
||||||
public List<PlondsComponentEntry> Components { get; set; } = [];
|
|
||||||
|
|
||||||
public List<PlondsFileEntry> Files { get; set; } = [];
|
|
||||||
}
|
|
||||||
|
|
||||||
internal sealed class PlondsComponentEntry
|
|
||||||
{
|
|
||||||
public string Name { get; set; } = string.Empty;
|
|
||||||
|
|
||||||
public string? Version { get; set; }
|
|
||||||
|
|
||||||
public Dictionary<string, string> Metadata { get; set; } = [];
|
|
||||||
|
|
||||||
public List<PlondsFileEntry> Files { get; set; } = [];
|
|
||||||
}
|
|
||||||
|
|
||||||
internal sealed class PlondsFileEntry
|
|
||||||
{
|
|
||||||
public string Path { get; set; } = string.Empty;
|
|
||||||
|
|
||||||
public string? Action { get; set; } = "replace";
|
|
||||||
|
|
||||||
public string? Url { get; set; }
|
|
||||||
|
|
||||||
public string? ObjectUrl { get; set; }
|
|
||||||
|
|
||||||
public string? ObjectPath { get; set; }
|
|
||||||
|
|
||||||
public string? ObjectKey { get; set; }
|
|
||||||
|
|
||||||
public string? ArchivePath { get; set; }
|
|
||||||
|
|
||||||
public string? Sha256 { get; set; }
|
|
||||||
|
|
||||||
public string? Sha512 { get; set; }
|
|
||||||
|
|
||||||
public string? Sha512Base64 { get; set; }
|
|
||||||
|
|
||||||
public byte[]? Sha512Bytes { get; set; }
|
|
||||||
|
|
||||||
public PlondsHashDescriptor? Hash { get; set; }
|
|
||||||
|
|
||||||
public Dictionary<string, string> Metadata { get; set; } = [];
|
|
||||||
}
|
|
||||||
|
|
||||||
internal sealed class PlondsHashDescriptor
|
|
||||||
{
|
|
||||||
public string? Algorithm { get; set; }
|
|
||||||
|
|
||||||
public string? Value { get; set; }
|
|
||||||
|
|
||||||
public byte[]? Bytes { get; set; }
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -2,7 +2,7 @@ using Avalonia.Threading;
|
|||||||
using LanMountainDesktop.Launcher.Models;
|
using LanMountainDesktop.Launcher.Models;
|
||||||
using LanMountainDesktop.Launcher.Views;
|
using LanMountainDesktop.Launcher.Views;
|
||||||
|
|
||||||
namespace LanMountainDesktop.Launcher.Services;
|
namespace LanMountainDesktop.Launcher.Oobe;
|
||||||
|
|
||||||
internal sealed class DataLocationOobeStep : IOobeStep
|
internal sealed class DataLocationOobeStep : IOobeStep
|
||||||
{
|
{
|
||||||
@@ -2,7 +2,7 @@ using System.Text.Json;
|
|||||||
using System.Text.Json.Nodes;
|
using System.Text.Json.Nodes;
|
||||||
using LanMountainDesktop.Shared.Contracts.Launcher;
|
using LanMountainDesktop.Shared.Contracts.Launcher;
|
||||||
|
|
||||||
namespace LanMountainDesktop.Launcher.Services;
|
namespace LanMountainDesktop.Launcher.Oobe;
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// 在 OOBE 中向 Host 的 settings.json 写入启动与展示相关字段,属性名与 Host
|
/// 在 OOBE 中向 Host 的 settings.json 写入启动与展示相关字段,属性名与 Host
|
||||||
@@ -1,4 +1,4 @@
|
|||||||
namespace LanMountainDesktop.Launcher.Services;
|
namespace LanMountainDesktop.Launcher.Oobe;
|
||||||
|
|
||||||
internal interface IOobeStep
|
internal interface IOobeStep
|
||||||
{
|
{
|
||||||
@@ -1,7 +1,7 @@
|
|||||||
using System;
|
using System;
|
||||||
using Microsoft.Win32;
|
using Microsoft.Win32;
|
||||||
|
|
||||||
namespace LanMountainDesktop.Launcher.Services;
|
namespace LanMountainDesktop.Launcher.Oobe;
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// 将当前 Windows 用户登录时自启动项指向<strong>本 Launcher 进程</strong>(与正式入口一致)。
|
/// 将当前 Windows 用户登录时自启动项指向<strong>本 Launcher 进程</strong>(与正式入口一致)。
|
||||||
@@ -1,7 +1,7 @@
|
|||||||
using System.Text.Json;
|
using System.Text.Json;
|
||||||
using LanMountainDesktop.Launcher.Models;
|
using LanMountainDesktop.Launcher.Models;
|
||||||
|
|
||||||
namespace LanMountainDesktop.Launcher.Services;
|
namespace LanMountainDesktop.Launcher.Oobe;
|
||||||
|
|
||||||
internal sealed class OobeStateService
|
internal sealed class OobeStateService
|
||||||
{
|
{
|
||||||
@@ -3,7 +3,7 @@ using System.Text;
|
|||||||
using System.Text.Json;
|
using System.Text.Json;
|
||||||
using LanMountainDesktop.Launcher.Models;
|
using LanMountainDesktop.Launcher.Models;
|
||||||
|
|
||||||
namespace LanMountainDesktop.Launcher.Services;
|
namespace LanMountainDesktop.Launcher.Oobe;
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// 隐私协议同意状态管理服务(带防篡改保护)
|
/// 隐私协议同意状态管理服务(带防篡改保护)
|
||||||
@@ -1,7 +1,7 @@
|
|||||||
using Avalonia.Threading;
|
using Avalonia.Threading;
|
||||||
using LanMountainDesktop.Launcher.Views;
|
using LanMountainDesktop.Launcher.Views;
|
||||||
|
|
||||||
namespace LanMountainDesktop.Launcher.Services;
|
namespace LanMountainDesktop.Launcher.Oobe;
|
||||||
|
|
||||||
internal sealed class WelcomeOobeStep : IOobeStep
|
internal sealed class WelcomeOobeStep : IOobeStep
|
||||||
{
|
{
|
||||||
@@ -2,7 +2,7 @@ using System.IO.Compression;
|
|||||||
using System.Text.Json;
|
using System.Text.Json;
|
||||||
using LanMountainDesktop.Launcher.Models;
|
using LanMountainDesktop.Launcher.Models;
|
||||||
|
|
||||||
namespace LanMountainDesktop.Launcher.Services;
|
namespace LanMountainDesktop.Launcher.Plugins;
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// 插件安装服务 - 简化版,不依赖 PluginSdk
|
/// 插件安装服务 - 简化版,不依赖 PluginSdk
|
||||||
@@ -22,7 +22,7 @@ internal sealed class PluginInstallerService
|
|||||||
TimeSpan.FromMilliseconds(500)
|
TimeSpan.FromMilliseconds(500)
|
||||||
];
|
];
|
||||||
|
|
||||||
public LauncherResult InstallPackage(string sourcePath, string pluginsDirectory)
|
public LauncherResult InstallPackage(string sourcePath, string pluginsDirectory, string? appRoot = null)
|
||||||
{
|
{
|
||||||
var fullSourcePath = Path.GetFullPath(sourcePath);
|
var fullSourcePath = Path.GetFullPath(sourcePath);
|
||||||
var fullPluginsDirectory = Path.GetFullPath(pluginsDirectory);
|
var fullPluginsDirectory = Path.GetFullPath(pluginsDirectory);
|
||||||
@@ -32,7 +32,7 @@ internal sealed class PluginInstallerService
|
|||||||
throw new FileNotFoundException($"Plugin package '{fullSourcePath}' was not found.", fullSourcePath);
|
throw new FileNotFoundException($"Plugin package '{fullSourcePath}' was not found.", fullSourcePath);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (TryBuildElevationRequiredResult(fullPluginsDirectory) is { } elevationRequiredResult)
|
if (TryBuildElevationRequiredResult(fullPluginsDirectory, appRoot) is { } elevationRequiredResult)
|
||||||
{
|
{
|
||||||
return elevationRequiredResult;
|
return elevationRequiredResult;
|
||||||
}
|
}
|
||||||
@@ -58,7 +58,7 @@ internal sealed class PluginInstallerService
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
private static LauncherResult? TryBuildElevationRequiredResult(string pluginsDirectory)
|
private static LauncherResult? TryBuildElevationRequiredResult(string pluginsDirectory, string? appRoot)
|
||||||
{
|
{
|
||||||
if (!OperatingSystem.IsWindows())
|
if (!OperatingSystem.IsWindows())
|
||||||
{
|
{
|
||||||
@@ -68,8 +68,10 @@ internal sealed class PluginInstallerService
|
|||||||
string? allowedRoot = null;
|
string? allowedRoot = null;
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
var appRoot = Commands.ResolveAppRoot(CommandContext.FromArgs([]));
|
var resolvedAppRoot = !string.IsNullOrWhiteSpace(appRoot)
|
||||||
var resolver = new DataLocationResolver(appRoot);
|
? Path.GetFullPath(appRoot)
|
||||||
|
: Commands.ResolveAppRoot(CommandContext.FromArgs([]));
|
||||||
|
var resolver = new DataLocationResolver(resolvedAppRoot);
|
||||||
allowedRoot = EnsureTrailingSeparator(resolver.ResolveDataRoot());
|
allowedRoot = EnsureTrailingSeparator(resolver.ResolveDataRoot());
|
||||||
}
|
}
|
||||||
catch
|
catch
|
||||||
@@ -290,7 +292,7 @@ internal sealed class PluginInstallerService
|
|||||||
/// <summary>
|
/// <summary>
|
||||||
/// 简化的插件清单模型
|
/// 简化的插件清单模型
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public class PluginManifest
|
internal class PluginManifest
|
||||||
{
|
{
|
||||||
public string Id { get; set; } = "";
|
public string Id { get; set; } = "";
|
||||||
public string Name { get; set; } = "";
|
public string Name { get; set; } = "";
|
||||||
@@ -1,7 +1,7 @@
|
|||||||
using System.Text.Json;
|
using System.Text.Json;
|
||||||
using LanMountainDesktop.Launcher.Models;
|
using LanMountainDesktop.Launcher.Models;
|
||||||
|
|
||||||
namespace LanMountainDesktop.Launcher.Services;
|
namespace LanMountainDesktop.Launcher.Plugins;
|
||||||
|
|
||||||
internal sealed class PluginUpgradeQueueService
|
internal sealed class PluginUpgradeQueueService
|
||||||
{
|
{
|
||||||
@@ -14,7 +14,7 @@ internal sealed class PluginUpgradeQueueService
|
|||||||
_installerService = installerService;
|
_installerService = installerService;
|
||||||
}
|
}
|
||||||
|
|
||||||
public LauncherResult ApplyPendingUpgrades(string pluginsDirectory)
|
public LauncherResult ApplyPendingUpgrades(string pluginsDirectory, string? appRoot = null)
|
||||||
{
|
{
|
||||||
var pendingPath = Path.Combine(pluginsDirectory, PendingUpgradesFileName);
|
var pendingPath = Path.Combine(pluginsDirectory, PendingUpgradesFileName);
|
||||||
if (!File.Exists(pendingPath))
|
if (!File.Exists(pendingPath))
|
||||||
@@ -29,7 +29,7 @@ internal sealed class PluginUpgradeQueueService
|
|||||||
}
|
}
|
||||||
|
|
||||||
var text = File.ReadAllText(pendingPath);
|
var text = File.ReadAllText(pendingPath);
|
||||||
var pending = JsonSerializer.Deserialize(text, AppJsonContext.Default.ListPendingUpgrade) ?? [];
|
var pending = JsonSerializer.Deserialize<List<PendingUpgrade>>(text, AppJsonContext.Default.Options) ?? [];
|
||||||
var failures = new List<string>();
|
var failures = new List<string>();
|
||||||
var succeeded = new List<PendingUpgrade>();
|
var succeeded = new List<PendingUpgrade>();
|
||||||
|
|
||||||
@@ -43,7 +43,7 @@ internal sealed class PluginUpgradeQueueService
|
|||||||
|
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
_installerService.InstallPackage(item.SourcePackagePath, pluginsDirectory);
|
_installerService.InstallPackage(item.SourcePackagePath, pluginsDirectory, appRoot);
|
||||||
succeeded.Add(item);
|
succeeded.Add(item);
|
||||||
}
|
}
|
||||||
catch
|
catch
|
||||||
@@ -63,7 +63,7 @@ internal sealed class PluginUpgradeQueueService
|
|||||||
}
|
}
|
||||||
else
|
else
|
||||||
{
|
{
|
||||||
File.WriteAllText(pendingPath, JsonSerializer.Serialize(remaining, AppJsonContext.Default.ListPendingUpgrade));
|
File.WriteAllText(pendingPath, JsonSerializer.Serialize(remaining, AppJsonContext.Default.Options));
|
||||||
}
|
}
|
||||||
|
|
||||||
return new LauncherResult
|
return new LauncherResult
|
||||||
@@ -1,7 +1,6 @@
|
|||||||
using Avalonia;
|
using Avalonia;
|
||||||
using LanMountainDesktop.Launcher.Models;
|
using LanMountainDesktop.Launcher.Models;
|
||||||
using LanMountainDesktop.Launcher.Services;
|
using LanMountainDesktop.Launcher.Shell;
|
||||||
|
|
||||||
namespace LanMountainDesktop.Launcher;
|
namespace LanMountainDesktop.Launcher;
|
||||||
|
|
||||||
public static class Program
|
public static class Program
|
||||||
@@ -34,6 +33,7 @@ public static class Program
|
|||||||
}
|
}
|
||||||
|
|
||||||
LauncherRuntimeContext.Current = commandContext;
|
LauncherRuntimeContext.Current = commandContext;
|
||||||
|
LauncherServiceRegistration.Initialize(commandContext);
|
||||||
|
|
||||||
var appRoot = Commands.ResolveAppRoot(commandContext);
|
var appRoot = Commands.ResolveAppRoot(commandContext);
|
||||||
var languageCode = LanguagePreferenceService.ResolveLanguageCode(appRoot);
|
var languageCode = LanguagePreferenceService.ResolveLanguageCode(appRoot);
|
||||||
|
|||||||
@@ -57,14 +57,6 @@
|
|||||||
"DOTNET_ENVIRONMENT": "Development"
|
"DOTNET_ENVIRONMENT": "Development"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"Launcher (Update Check)": {
|
|
||||||
"commandName": "Project",
|
|
||||||
"commandLineArgs": "update check",
|
|
||||||
"workingDirectory": "$(SolutionDir)",
|
|
||||||
"environmentVariables": {
|
|
||||||
"DOTNET_ENVIRONMENT": "Development"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"Launcher (Plugin Install)": {
|
"Launcher (Plugin Install)": {
|
||||||
"commandName": "Project",
|
"commandName": "Project",
|
||||||
"commandLineArgs": "plugin install <path-to-plugin.laapp>",
|
"commandLineArgs": "plugin install <path-to-plugin.laapp>",
|
||||||
|
|||||||
@@ -1,29 +0,0 @@
|
|||||||
using LanMountainDesktop.Shared.IPC;
|
|
||||||
using LanMountainDesktop.Shared.IPC.Abstractions.Services;
|
|
||||||
|
|
||||||
namespace LanMountainDesktop.Launcher.Services.AirApp;
|
|
||||||
|
|
||||||
internal sealed class LauncherAirAppLifecycleIpcHost : IDisposable
|
|
||||||
{
|
|
||||||
private readonly PublicIpcHostService _host;
|
|
||||||
|
|
||||||
public LauncherAirAppLifecycleIpcHost(LauncherAirAppLifecycleService lifecycleService)
|
|
||||||
{
|
|
||||||
LifecycleService = lifecycleService;
|
|
||||||
_host = new PublicIpcHostService(IpcConstants.AirAppLifecyclePipeName);
|
|
||||||
_host.RegisterPublicService<IAirAppLifecycleService>(lifecycleService);
|
|
||||||
}
|
|
||||||
|
|
||||||
public LauncherAirAppLifecycleService LifecycleService { get; }
|
|
||||||
|
|
||||||
public void Start()
|
|
||||||
{
|
|
||||||
_host.Start();
|
|
||||||
Logger.Info($"Air APP lifecycle IPC started. Pipe='{IpcConstants.AirAppLifecyclePipeName}'.");
|
|
||||||
}
|
|
||||||
|
|
||||||
public void Dispose()
|
|
||||||
{
|
|
||||||
_host.Dispose();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,634 +0,0 @@
|
|||||||
using System.Diagnostics;
|
|
||||||
using System.Text.Json;
|
|
||||||
|
|
||||||
namespace LanMountainDesktop.Launcher.Services;
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// 灵活的主程序定位器
|
|
||||||
/// </summary>
|
|
||||||
internal sealed class FlexibleHostLocator
|
|
||||||
{
|
|
||||||
private readonly HostDiscoveryOptions _options;
|
|
||||||
private readonly string _appRoot;
|
|
||||||
private readonly DeploymentLocator _deploymentLocator;
|
|
||||||
|
|
||||||
public FlexibleHostLocator(string appRoot, HostDiscoveryOptions? options = null)
|
|
||||||
{
|
|
||||||
_appRoot = appRoot;
|
|
||||||
_options = options ?? new HostDiscoveryOptions();
|
|
||||||
_deploymentLocator = new DeploymentLocator(appRoot);
|
|
||||||
}
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// 解析主程序可执行文件路径
|
|
||||||
/// </summary>
|
|
||||||
public string? ResolveHostExecutablePath()
|
|
||||||
{
|
|
||||||
var executable = GetExecutableName();
|
|
||||||
var searchContext = new SearchContext
|
|
||||||
{
|
|
||||||
ExecutableName = executable,
|
|
||||||
AppRoot = _appRoot,
|
|
||||||
Options = _options
|
|
||||||
};
|
|
||||||
|
|
||||||
// ========== 第一阶段:标准路径查找(快速路径)==========
|
|
||||||
|
|
||||||
// 1. 检查环境变量指定的路径(最高优先级 - 用于调试和特殊场景)
|
|
||||||
var envPath = GetPathFromEnvironment();
|
|
||||||
if (!string.IsNullOrWhiteSpace(envPath))
|
|
||||||
{
|
|
||||||
var validated = ValidateAndReturn(envPath, "environment variable");
|
|
||||||
if (validated != null) return validated;
|
|
||||||
}
|
|
||||||
|
|
||||||
// 2. 使用 DeploymentLocator(ClassIsland 风格的简洁查询 - 优先)
|
|
||||||
Console.WriteLine("[FlexibleHostLocator] Trying quick path: DeploymentLocator.FindCurrentDeploymentDirectory()");
|
|
||||||
var deploymentDir = _deploymentLocator.FindCurrentDeploymentDirectory();
|
|
||||||
if (!string.IsNullOrWhiteSpace(deploymentDir))
|
|
||||||
{
|
|
||||||
var deploymentExePath = Path.Combine(deploymentDir, executable);
|
|
||||||
if (File.Exists(deploymentExePath))
|
|
||||||
{
|
|
||||||
Console.WriteLine($"[FlexibleHostLocator] Quick path found: {deploymentExePath}");
|
|
||||||
return deploymentExePath;
|
|
||||||
}
|
|
||||||
Console.WriteLine($"[FlexibleHostLocator] Quick path found dir but no exe: {deploymentExePath}");
|
|
||||||
}
|
|
||||||
|
|
||||||
// 3. 快速路径失败,尝试旧的 SearchDeploymentDirectories 作为 fallback
|
|
||||||
Console.WriteLine("[FlexibleHostLocator] Quick path failed, falling back to SearchDeploymentDirectories");
|
|
||||||
var deploymentPath = SearchDeploymentDirectories(searchContext);
|
|
||||||
if (!string.IsNullOrWhiteSpace(deploymentPath))
|
|
||||||
{
|
|
||||||
return deploymentPath;
|
|
||||||
}
|
|
||||||
|
|
||||||
// 4. 检查 Launcher 同级目录(便携模式)
|
|
||||||
var portablePath = SearchPortableLocation(searchContext);
|
|
||||||
if (!string.IsNullOrWhiteSpace(portablePath))
|
|
||||||
{
|
|
||||||
return portablePath;
|
|
||||||
}
|
|
||||||
|
|
||||||
// ========== 第二阶段:灵活查找(标准路径找不到时)==========
|
|
||||||
|
|
||||||
// 5. 检查配置文件中的路径 - 用户自定义配置
|
|
||||||
var configPath = GetPathFromConfigFile();
|
|
||||||
if (!string.IsNullOrWhiteSpace(configPath))
|
|
||||||
{
|
|
||||||
var validated = ValidateAndReturn(configPath, "config file");
|
|
||||||
if (validated != null) return validated;
|
|
||||||
}
|
|
||||||
|
|
||||||
// 5. 搜索附近目录(向上、向下各一层)
|
|
||||||
var nearbyPath = SearchNearbyDirectories(searchContext);
|
|
||||||
if (!string.IsNullOrWhiteSpace(nearbyPath))
|
|
||||||
{
|
|
||||||
return nearbyPath;
|
|
||||||
}
|
|
||||||
|
|
||||||
// 7. 开发模式:检查保存的自定义路径
|
|
||||||
if (_options.PreferDevModeConfig && Views.ErrorWindow.CheckDevModeEnabled())
|
|
||||||
{
|
|
||||||
var savedPath = Views.ErrorWindow.GetSavedCustomHostPath();
|
|
||||||
if (!string.IsNullOrWhiteSpace(savedPath))
|
|
||||||
{
|
|
||||||
var validated = ValidateAndReturn(savedPath, "saved dev mode path");
|
|
||||||
if (validated != null) return validated;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// 8. 搜索标准开发路径
|
|
||||||
var devPath = SearchDevelopmentPaths(searchContext);
|
|
||||||
if (!string.IsNullOrWhiteSpace(devPath))
|
|
||||||
{
|
|
||||||
return devPath;
|
|
||||||
}
|
|
||||||
|
|
||||||
// 9. 搜索额外的配置路径
|
|
||||||
var additionalPath = SearchAdditionalPaths(searchContext);
|
|
||||||
if (!string.IsNullOrWhiteSpace(additionalPath))
|
|
||||||
{
|
|
||||||
return additionalPath;
|
|
||||||
}
|
|
||||||
|
|
||||||
// 10. 递归搜索(如果启用)
|
|
||||||
if (_options.RecursiveSearch)
|
|
||||||
{
|
|
||||||
var recursivePath = SearchRecursively(searchContext);
|
|
||||||
if (!string.IsNullOrWhiteSpace(recursivePath))
|
|
||||||
{
|
|
||||||
return recursivePath;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// 从环境变量获取路径
|
|
||||||
/// </summary>
|
|
||||||
private string? GetPathFromEnvironment()
|
|
||||||
{
|
|
||||||
if (string.IsNullOrWhiteSpace(_options.CustomPathEnvVar))
|
|
||||||
{
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
var path = Environment.GetEnvironmentVariable(_options.CustomPathEnvVar);
|
|
||||||
return path;
|
|
||||||
}
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// 从配置文件获取路径
|
|
||||||
/// </summary>
|
|
||||||
private string? GetPathFromConfigFile()
|
|
||||||
{
|
|
||||||
if (string.IsNullOrWhiteSpace(_options.ConfigFileName))
|
|
||||||
{
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
var configPath = Path.Combine(_appRoot, _options.ConfigFileName);
|
|
||||||
if (!File.Exists(configPath))
|
|
||||||
{
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
try
|
|
||||||
{
|
|
||||||
var json = File.ReadAllText(configPath);
|
|
||||||
var config = JsonSerializer.Deserialize(json, AppJsonContext.Default.HostDiscoveryConfig);
|
|
||||||
if (config?.HostPath != null && File.Exists(config.HostPath))
|
|
||||||
{
|
|
||||||
return config.HostPath;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
catch
|
|
||||||
{
|
|
||||||
// 忽略配置文件读取错误
|
|
||||||
}
|
|
||||||
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// 搜索部署目录
|
|
||||||
/// </summary>
|
|
||||||
private string? SearchDeploymentDirectories(SearchContext context)
|
|
||||||
{
|
|
||||||
if (!Directory.Exists(_appRoot))
|
|
||||||
{
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
try
|
|
||||||
{
|
|
||||||
// 查找 app-* 目录
|
|
||||||
var appDirs = Directory.GetDirectories(_appRoot, "app-*", SearchOption.TopDirectoryOnly)
|
|
||||||
.Where(dir => !File.Exists(Path.Combine(dir, ".destroy")))
|
|
||||||
.Where(dir => !File.Exists(Path.Combine(dir, ".partial")))
|
|
||||||
.ToList();
|
|
||||||
|
|
||||||
// 优先选择带 .current 标记的
|
|
||||||
var currentMarked = appDirs
|
|
||||||
.Where(dir => File.Exists(Path.Combine(dir, ".current")))
|
|
||||||
.Select(dir => Path.Combine(dir, context.ExecutableName))
|
|
||||||
.FirstOrDefault(File.Exists);
|
|
||||||
|
|
||||||
if (currentMarked != null)
|
|
||||||
{
|
|
||||||
return currentMarked;
|
|
||||||
}
|
|
||||||
|
|
||||||
// 选择版本号最高的
|
|
||||||
var latest = appDirs
|
|
||||||
.Select(dir => new
|
|
||||||
{
|
|
||||||
Dir = dir,
|
|
||||||
Version = ParseVersionFromDirectoryName(dir)
|
|
||||||
})
|
|
||||||
.OrderByDescending(x => x.Version)
|
|
||||||
.Select(x => Path.Combine(x.Dir, context.ExecutableName))
|
|
||||||
.FirstOrDefault(File.Exists);
|
|
||||||
|
|
||||||
return latest;
|
|
||||||
}
|
|
||||||
catch
|
|
||||||
{
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// 搜索便携模式位置(Launcher 同级目录)
|
|
||||||
/// </summary>
|
|
||||||
private string? SearchPortableLocation(SearchContext context)
|
|
||||||
{
|
|
||||||
try
|
|
||||||
{
|
|
||||||
var launcherDir = AppContext.BaseDirectory;
|
|
||||||
var portablePath = Path.Combine(launcherDir, context.ExecutableName);
|
|
||||||
|
|
||||||
if (File.Exists(portablePath))
|
|
||||||
{
|
|
||||||
return portablePath;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
catch
|
|
||||||
{
|
|
||||||
// 忽略错误
|
|
||||||
}
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// 搜索附近目录(灵活查找,适用于各种部署场景)
|
|
||||||
/// </summary>
|
|
||||||
private string? SearchNearbyDirectories(SearchContext context)
|
|
||||||
{
|
|
||||||
try
|
|
||||||
{
|
|
||||||
var searchDirs = new List<string>();
|
|
||||||
|
|
||||||
// Launcher 所在目录
|
|
||||||
var launcherDir = AppContext.BaseDirectory;
|
|
||||||
searchDirs.Add(launcherDir);
|
|
||||||
|
|
||||||
// 上级目录
|
|
||||||
var parentDir = Path.GetFullPath(Path.Combine(launcherDir, ".."));
|
|
||||||
if (Directory.Exists(parentDir))
|
|
||||||
{
|
|
||||||
searchDirs.Add(parentDir);
|
|
||||||
}
|
|
||||||
|
|
||||||
// 上上级目录
|
|
||||||
var grandparentDir = Path.GetFullPath(Path.Combine(launcherDir, "..", ".."));
|
|
||||||
if (Directory.Exists(grandparentDir))
|
|
||||||
{
|
|
||||||
searchDirs.Add(grandparentDir);
|
|
||||||
}
|
|
||||||
|
|
||||||
// AppRoot 及其上级
|
|
||||||
if (!string.IsNullOrWhiteSpace(_appRoot) && Directory.Exists(_appRoot))
|
|
||||||
{
|
|
||||||
searchDirs.Add(_appRoot);
|
|
||||||
var appParent = Path.GetFullPath(Path.Combine(_appRoot, ".."));
|
|
||||||
if (Directory.Exists(appParent))
|
|
||||||
{
|
|
||||||
searchDirs.Add(appParent);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// 去重后搜索
|
|
||||||
foreach (var dir in searchDirs.Distinct(StringComparer.OrdinalIgnoreCase))
|
|
||||||
{
|
|
||||||
// 直接搜索
|
|
||||||
var directPath = Path.Combine(dir, context.ExecutableName);
|
|
||||||
if (File.Exists(directPath))
|
|
||||||
{
|
|
||||||
return directPath;
|
|
||||||
}
|
|
||||||
|
|
||||||
// 搜索子目录(一层)
|
|
||||||
if (Directory.Exists(dir))
|
|
||||||
{
|
|
||||||
foreach (var subDir in Directory.GetDirectories(dir))
|
|
||||||
{
|
|
||||||
var subPath = Path.Combine(subDir, context.ExecutableName);
|
|
||||||
if (File.Exists(subPath))
|
|
||||||
{
|
|
||||||
return subPath;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
catch
|
|
||||||
{
|
|
||||||
// 忽略搜索错误
|
|
||||||
}
|
|
||||||
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// 搜索开发路径
|
|
||||||
/// </summary>
|
|
||||||
private string? SearchDevelopmentPaths(SearchContext context)
|
|
||||||
{
|
|
||||||
// 获取 Launcher 所在目录
|
|
||||||
var launcherDir = AppContext.BaseDirectory;
|
|
||||||
|
|
||||||
// 动态构建可能的开发路径(支持不同的项目结构)
|
|
||||||
var possiblePaths = new List<string>();
|
|
||||||
|
|
||||||
// 从解决方案根目录搜索(支持不同的解决方案结构)
|
|
||||||
var solutionRoot = FindSolutionRoot(launcherDir);
|
|
||||||
if (!string.IsNullOrWhiteSpace(solutionRoot))
|
|
||||||
{
|
|
||||||
// 搜索所有可能的 bin 目录
|
|
||||||
possiblePaths.AddRange(SearchBinDirectories(solutionRoot, context.ExecutableName));
|
|
||||||
}
|
|
||||||
|
|
||||||
// 添加硬编码的备用路径
|
|
||||||
possiblePaths.AddRange(new[]
|
|
||||||
{
|
|
||||||
Path.Combine(launcherDir, "..", "..", "LanMountainDesktop", "bin", "Debug", "net10.0", context.ExecutableName),
|
|
||||||
Path.Combine(launcherDir, "..", "..", "LanMountainDesktop", "bin", "Release", "net10.0", context.ExecutableName),
|
|
||||||
Path.Combine(launcherDir, "..", "LanMountainDesktop", "bin", "Debug", "net10.0", context.ExecutableName),
|
|
||||||
Path.Combine(launcherDir, "..", "LanMountainDesktop", "bin", "Release", "net10.0", context.ExecutableName),
|
|
||||||
});
|
|
||||||
|
|
||||||
foreach (var path in possiblePaths.Select(Path.GetFullPath).Distinct())
|
|
||||||
{
|
|
||||||
if (File.Exists(path))
|
|
||||||
{
|
|
||||||
return path;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// 搜索额外的配置路径
|
|
||||||
/// </summary>
|
|
||||||
private string? SearchAdditionalPaths(SearchContext context)
|
|
||||||
{
|
|
||||||
if (_options.AdditionalSearchPaths == null || !_options.AdditionalSearchPaths.Any())
|
|
||||||
{
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
foreach (var pattern in _options.AdditionalSearchPaths)
|
|
||||||
{
|
|
||||||
try
|
|
||||||
{
|
|
||||||
// 替换变量
|
|
||||||
var expandedPattern = ExpandVariables(pattern);
|
|
||||||
|
|
||||||
// 支持通配符
|
|
||||||
if (expandedPattern.Contains('*') || expandedPattern.Contains('?'))
|
|
||||||
{
|
|
||||||
var dir = Path.GetDirectoryName(expandedPattern) ?? _appRoot;
|
|
||||||
var filePattern = Path.GetFileName(expandedPattern);
|
|
||||||
|
|
||||||
if (Directory.Exists(dir))
|
|
||||||
{
|
|
||||||
var matches = Directory.GetFiles(dir, filePattern, SearchOption.TopDirectoryOnly);
|
|
||||||
var validMatch = matches.FirstOrDefault(File.Exists);
|
|
||||||
if (validMatch != null)
|
|
||||||
{
|
|
||||||
return validMatch;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
else if (File.Exists(expandedPattern))
|
|
||||||
{
|
|
||||||
return expandedPattern;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
catch
|
|
||||||
{
|
|
||||||
// 忽略搜索错误
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// 递归搜索
|
|
||||||
/// </summary>
|
|
||||||
private string? SearchRecursively(SearchContext context)
|
|
||||||
{
|
|
||||||
try
|
|
||||||
{
|
|
||||||
var searchDirs = new[] { _appRoot, Path.GetFullPath(Path.Combine(_appRoot, "..")) };
|
|
||||||
|
|
||||||
foreach (var searchDir in searchDirs.Where(Directory.Exists))
|
|
||||||
{
|
|
||||||
var result = SearchDirectoryRecursively(searchDir, context.ExecutableName, 0);
|
|
||||||
if (result != null)
|
|
||||||
{
|
|
||||||
return result;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
catch
|
|
||||||
{
|
|
||||||
// 忽略递归搜索错误
|
|
||||||
}
|
|
||||||
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// 递归搜索目录
|
|
||||||
/// </summary>
|
|
||||||
private string? SearchDirectoryRecursively(string dir, string executableName, int depth)
|
|
||||||
{
|
|
||||||
if (depth > _options.MaxRecursionDepth)
|
|
||||||
{
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
try
|
|
||||||
{
|
|
||||||
// 检查当前目录
|
|
||||||
var directPath = Path.Combine(dir, executableName);
|
|
||||||
if (File.Exists(directPath))
|
|
||||||
{
|
|
||||||
return directPath;
|
|
||||||
}
|
|
||||||
|
|
||||||
// 检查子目录
|
|
||||||
foreach (var subDir in Directory.GetDirectories(dir))
|
|
||||||
{
|
|
||||||
// 跳过某些目录
|
|
||||||
var dirName = Path.GetFileName(subDir).ToLowerInvariant();
|
|
||||||
if (dirName is ".git" or "node_modules" or ".vs" or "obj" or ".launcher")
|
|
||||||
{
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
var result = SearchDirectoryRecursively(subDir, executableName, depth + 1);
|
|
||||||
if (result != null)
|
|
||||||
{
|
|
||||||
return result;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
catch
|
|
||||||
{
|
|
||||||
// 忽略访问错误
|
|
||||||
}
|
|
||||||
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// 查找解决方案根目录
|
|
||||||
/// </summary>
|
|
||||||
private string? FindSolutionRoot(string startDir)
|
|
||||||
{
|
|
||||||
var current = new DirectoryInfo(startDir);
|
|
||||||
while (current != null)
|
|
||||||
{
|
|
||||||
// 查找 .sln 文件
|
|
||||||
if (current.GetFiles("*.sln").Any())
|
|
||||||
{
|
|
||||||
return current.FullName;
|
|
||||||
}
|
|
||||||
|
|
||||||
// 查找 .git 目录作为备选
|
|
||||||
if (current.GetDirectories(".git").Any())
|
|
||||||
{
|
|
||||||
return current.FullName;
|
|
||||||
}
|
|
||||||
|
|
||||||
current = current.Parent;
|
|
||||||
}
|
|
||||||
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// 搜索 bin 目录
|
|
||||||
/// </summary>
|
|
||||||
private IEnumerable<string> SearchBinDirectories(string root, string executableName)
|
|
||||||
{
|
|
||||||
var results = new List<string>();
|
|
||||||
|
|
||||||
try
|
|
||||||
{
|
|
||||||
// 查找所有 bin 目录
|
|
||||||
var binDirs = Directory.GetDirectories(root, "bin", SearchOption.AllDirectories);
|
|
||||||
|
|
||||||
foreach (var binDir in binDirs)
|
|
||||||
{
|
|
||||||
// 检查 Debug 和 Release 子目录
|
|
||||||
var configDirs = new[] { "Debug", "Release" };
|
|
||||||
foreach (var config in configDirs)
|
|
||||||
{
|
|
||||||
var configPath = Path.Combine(binDir, config);
|
|
||||||
if (Directory.Exists(configPath))
|
|
||||||
{
|
|
||||||
// 检查所有 net* 子目录
|
|
||||||
var frameworkDirs = Directory.GetDirectories(configPath, "net*");
|
|
||||||
foreach (var fwDir in frameworkDirs)
|
|
||||||
{
|
|
||||||
var exePath = Path.Combine(fwDir, executableName);
|
|
||||||
if (File.Exists(exePath))
|
|
||||||
{
|
|
||||||
results.Add(exePath);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
catch
|
|
||||||
{
|
|
||||||
// 忽略搜索错误
|
|
||||||
}
|
|
||||||
|
|
||||||
return results;
|
|
||||||
}
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// 验证路径并返回
|
|
||||||
/// </summary>
|
|
||||||
private string? ValidateAndReturn(string path, string source)
|
|
||||||
{
|
|
||||||
if (File.Exists(path))
|
|
||||||
{
|
|
||||||
Debug.WriteLine($"Found host executable from {source}: {path}");
|
|
||||||
return path;
|
|
||||||
}
|
|
||||||
|
|
||||||
// 尝试添加 .exe(Windows)
|
|
||||||
if (OperatingSystem.IsWindows() && !path.EndsWith(".exe", StringComparison.OrdinalIgnoreCase))
|
|
||||||
{
|
|
||||||
var withExe = path + ".exe";
|
|
||||||
if (File.Exists(withExe))
|
|
||||||
{
|
|
||||||
Debug.WriteLine($"Found host executable from {source}: {withExe}");
|
|
||||||
return withExe;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (string.Equals(source, "saved dev mode path", StringComparison.OrdinalIgnoreCase))
|
|
||||||
{
|
|
||||||
Logger.Warn($"Saved launcher debug host path is invalid; continuing host discovery. Path='{path}'.");
|
|
||||||
}
|
|
||||||
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// 获取可执行文件名
|
|
||||||
/// </summary>
|
|
||||||
private string GetExecutableName()
|
|
||||||
{
|
|
||||||
var name = _options.ExecutableName;
|
|
||||||
if (OperatingSystem.IsWindows() && !name.EndsWith(".exe", StringComparison.OrdinalIgnoreCase))
|
|
||||||
{
|
|
||||||
name += ".exe";
|
|
||||||
}
|
|
||||||
return name;
|
|
||||||
}
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// 展开路径变量
|
|
||||||
/// </summary>
|
|
||||||
private string ExpandVariables(string path)
|
|
||||||
{
|
|
||||||
return path
|
|
||||||
.Replace("${AppRoot}", _appRoot)
|
|
||||||
.Replace("${BaseDirectory}", AppContext.BaseDirectory)
|
|
||||||
.Replace("${UserProfile}", Environment.GetFolderPath(Environment.SpecialFolder.UserProfile))
|
|
||||||
.Replace("${LocalAppData}", Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData));
|
|
||||||
}
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// 从目录名解析版本
|
|
||||||
/// </summary>
|
|
||||||
private static Version ParseVersionFromDirectoryName(string path)
|
|
||||||
{
|
|
||||||
var fileName = Path.GetFileName(path);
|
|
||||||
if (string.IsNullOrWhiteSpace(fileName))
|
|
||||||
{
|
|
||||||
return new Version(0, 0, 0);
|
|
||||||
}
|
|
||||||
|
|
||||||
var segments = fileName.Split('-');
|
|
||||||
if (segments.Length < 2)
|
|
||||||
{
|
|
||||||
return new Version(0, 0, 0);
|
|
||||||
}
|
|
||||||
|
|
||||||
return Version.TryParse(segments[1], out var version) ? version : new Version(0, 0, 0);
|
|
||||||
}
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// 搜索上下文
|
|
||||||
/// </summary>
|
|
||||||
private class SearchContext
|
|
||||||
{
|
|
||||||
public required string ExecutableName { get; set; }
|
|
||||||
public required string AppRoot { get; set; }
|
|
||||||
public required HostDiscoveryOptions Options { get; set; }
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// 发现配置文件
|
|
||||||
/// </summary>
|
|
||||||
internal class HostDiscoveryConfig
|
|
||||||
{
|
|
||||||
public string? HostPath { get; set; }
|
|
||||||
public List<string>? AdditionalPaths { get; set; }
|
|
||||||
}
|
|
||||||
@@ -1,9 +0,0 @@
|
|||||||
using LanMountainDesktop.Shared.Contracts.Update;
|
|
||||||
|
|
||||||
namespace LanMountainDesktop.Launcher.Services;
|
|
||||||
|
|
||||||
public interface IUpdateProgressReporter
|
|
||||||
{
|
|
||||||
void ReportProgress(InstallProgressReport report);
|
|
||||||
void ReportComplete(InstallCompleteReport report);
|
|
||||||
}
|
|
||||||
@@ -1,132 +0,0 @@
|
|||||||
using System.Buffers;
|
|
||||||
using System.IO.Pipes;
|
|
||||||
using System.Text;
|
|
||||||
using System.Text.Json;
|
|
||||||
using LanMountainDesktop.Launcher.Services;
|
|
||||||
using LanMountainDesktop.Shared.Contracts.Update;
|
|
||||||
|
|
||||||
namespace LanMountainDesktop.Launcher.Services.Ipc;
|
|
||||||
|
|
||||||
internal sealed class LauncherUpdateProgressIpcServer : IUpdateProgressReporter, IDisposable
|
|
||||||
{
|
|
||||||
private const int LengthPrefixSize = 4;
|
|
||||||
|
|
||||||
private readonly string _pipeName;
|
|
||||||
private readonly CancellationTokenSource _cts = new();
|
|
||||||
private NamedPipeServerStream? _pipe;
|
|
||||||
private Task? _listenTask;
|
|
||||||
private volatile bool _clientConnected;
|
|
||||||
|
|
||||||
public LauncherUpdateProgressIpcServer(int launcherPid)
|
|
||||||
{
|
|
||||||
_pipeName = $"LanMountainDesktop_Update_{launcherPid}";
|
|
||||||
}
|
|
||||||
|
|
||||||
public string PipeName => _pipeName;
|
|
||||||
|
|
||||||
public void Start()
|
|
||||||
{
|
|
||||||
_listenTask = Task.Run(AcceptConnectionAsync, _cts.Token);
|
|
||||||
}
|
|
||||||
|
|
||||||
private async Task AcceptConnectionAsync()
|
|
||||||
{
|
|
||||||
while (!_cts.Token.IsCancellationRequested)
|
|
||||||
{
|
|
||||||
try
|
|
||||||
{
|
|
||||||
_pipe = new NamedPipeServerStream(
|
|
||||||
_pipeName,
|
|
||||||
PipeDirection.Out,
|
|
||||||
1,
|
|
||||||
PipeTransmissionMode.Byte,
|
|
||||||
PipeOptions.Asynchronous);
|
|
||||||
|
|
||||||
await _pipe.WaitForConnectionAsync(_cts.Token).ConfigureAwait(false);
|
|
||||||
_clientConnected = true;
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
catch (OperationCanceledException)
|
|
||||||
{
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
catch (Exception ex)
|
|
||||||
{
|
|
||||||
Logger.Warn($"Update progress IPC listen error: {ex.Message}");
|
|
||||||
try
|
|
||||||
{
|
|
||||||
await Task.Delay(200, _cts.Token).ConfigureAwait(false);
|
|
||||||
}
|
|
||||||
catch (OperationCanceledException)
|
|
||||||
{
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
public void ReportProgress(InstallProgressReport report)
|
|
||||||
{
|
|
||||||
if (!_clientConnected || _pipe is null || !_pipe.IsConnected)
|
|
||||||
{
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
try
|
|
||||||
{
|
|
||||||
WriteMessage(_pipe, JsonSerializer.Serialize(report, AppJsonContext.Default.InstallProgressReport));
|
|
||||||
}
|
|
||||||
catch (Exception ex)
|
|
||||||
{
|
|
||||||
Logger.Warn($"Failed to report progress via IPC: {ex.Message}");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
public void ReportComplete(InstallCompleteReport report)
|
|
||||||
{
|
|
||||||
if (!_clientConnected || _pipe is null || !_pipe.IsConnected)
|
|
||||||
{
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
try
|
|
||||||
{
|
|
||||||
WriteMessage(_pipe, JsonSerializer.Serialize(report, AppJsonContext.Default.InstallCompleteReport));
|
|
||||||
}
|
|
||||||
catch (Exception ex)
|
|
||||||
{
|
|
||||||
Logger.Warn($"Failed to report completion via IPC: {ex.Message}");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private static void WriteMessage(Stream stream, string json)
|
|
||||||
{
|
|
||||||
var payload = Encoding.UTF8.GetBytes(json);
|
|
||||||
var lengthPrefix = BitConverter.GetBytes(payload.Length);
|
|
||||||
stream.Write(lengthPrefix, 0, LengthPrefixSize);
|
|
||||||
stream.Write(payload, 0, payload.Length);
|
|
||||||
stream.Flush();
|
|
||||||
}
|
|
||||||
|
|
||||||
public void Dispose()
|
|
||||||
{
|
|
||||||
_cts.Cancel();
|
|
||||||
try
|
|
||||||
{
|
|
||||||
_pipe?.Dispose();
|
|
||||||
}
|
|
||||||
catch
|
|
||||||
{
|
|
||||||
}
|
|
||||||
|
|
||||||
try
|
|
||||||
{
|
|
||||||
_listenTask?.Wait(TimeSpan.FromSeconds(2));
|
|
||||||
}
|
|
||||||
catch
|
|
||||||
{
|
|
||||||
}
|
|
||||||
|
|
||||||
_cts.Dispose();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
File diff suppressed because it is too large
Load Diff
@@ -1,9 +0,0 @@
|
|||||||
using LanMountainDesktop.Shared.Contracts.Update;
|
|
||||||
|
|
||||||
namespace LanMountainDesktop.Launcher.Services;
|
|
||||||
|
|
||||||
internal sealed class NullUpdateProgressReporter : IUpdateProgressReporter
|
|
||||||
{
|
|
||||||
public void ReportProgress(InstallProgressReport report) { }
|
|
||||||
public void ReportComplete(InstallCompleteReport report) { }
|
|
||||||
}
|
|
||||||
@@ -1,161 +0,0 @@
|
|||||||
using System.Net.Http;
|
|
||||||
using System.Net.Http.Json;
|
|
||||||
using System.Text.Json;
|
|
||||||
using System.Text.Json.Serialization;
|
|
||||||
using LanMountainDesktop.Launcher.Models;
|
|
||||||
|
|
||||||
namespace LanMountainDesktop.Launcher.Services;
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// 更新检查服务 - 基于 GitHub Release API
|
|
||||||
/// </summary>
|
|
||||||
internal sealed class UpdateCheckService
|
|
||||||
{
|
|
||||||
private const string GitHubApiBase = "https://api.github.com";
|
|
||||||
private readonly string _repoOwner;
|
|
||||||
private readonly string _repoName;
|
|
||||||
private readonly HttpClient _httpClient;
|
|
||||||
|
|
||||||
public UpdateCheckService(string repoOwner, string repoName)
|
|
||||||
{
|
|
||||||
_repoOwner = repoOwner;
|
|
||||||
_repoName = repoName;
|
|
||||||
_httpClient = new HttpClient();
|
|
||||||
_httpClient.DefaultRequestHeaders.Add("User-Agent", "LanMountainDesktop-Launcher");
|
|
||||||
_httpClient.DefaultRequestHeaders.Add("Accept", "application/vnd.github+json");
|
|
||||||
}
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// 检查更新
|
|
||||||
/// </summary>
|
|
||||||
public async Task<UpdateCheckResult> CheckForUpdateAsync(
|
|
||||||
string currentVersion,
|
|
||||||
UpdateChannel channel,
|
|
||||||
CancellationToken cancellationToken = default)
|
|
||||||
{
|
|
||||||
try
|
|
||||||
{
|
|
||||||
var releases = await FetchReleasesAsync(cancellationToken);
|
|
||||||
|
|
||||||
// 根据频道过滤版本
|
|
||||||
var filteredReleases = channel == UpdateChannel.Stable
|
|
||||||
? releases.Where(r => !r.Prerelease).ToList()
|
|
||||||
: releases;
|
|
||||||
|
|
||||||
// 找到最新版本
|
|
||||||
var latestRelease = filteredReleases
|
|
||||||
.OrderByDescending(r => ParseVersion(r.TagName))
|
|
||||||
.FirstOrDefault();
|
|
||||||
|
|
||||||
if (latestRelease == null)
|
|
||||||
{
|
|
||||||
return new UpdateCheckResult
|
|
||||||
{
|
|
||||||
HasUpdate = false,
|
|
||||||
CurrentVersion = currentVersion,
|
|
||||||
ErrorMessage = "No releases found"
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
var latestVersion = ParseVersionString(latestRelease.TagName);
|
|
||||||
var current = ParseVersion(currentVersion);
|
|
||||||
var latest = ParseVersion(latestVersion);
|
|
||||||
|
|
||||||
return new UpdateCheckResult
|
|
||||||
{
|
|
||||||
HasUpdate = latest > current,
|
|
||||||
LatestVersion = latestVersion,
|
|
||||||
CurrentVersion = currentVersion,
|
|
||||||
Release = latestRelease
|
|
||||||
};
|
|
||||||
}
|
|
||||||
catch (Exception ex)
|
|
||||||
{
|
|
||||||
return new UpdateCheckResult
|
|
||||||
{
|
|
||||||
HasUpdate = false,
|
|
||||||
CurrentVersion = currentVersion,
|
|
||||||
ErrorMessage = ex.Message
|
|
||||||
};
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// 获取所有 Release
|
|
||||||
/// </summary>
|
|
||||||
private async Task<List<ReleaseInfo>> FetchReleasesAsync(CancellationToken cancellationToken)
|
|
||||||
{
|
|
||||||
var url = $"{GitHubApiBase}/repos/{_repoOwner}/{_repoName}/releases";
|
|
||||||
var response = await _httpClient.GetAsync(url, cancellationToken);
|
|
||||||
response.EnsureSuccessStatusCode();
|
|
||||||
|
|
||||||
var json = await response.Content.ReadAsStringAsync(cancellationToken);
|
|
||||||
var releases = JsonSerializer.Deserialize(json, AppJsonContext.Default.ListGitHubRelease);
|
|
||||||
|
|
||||||
return releases?.Select(r => new ReleaseInfo
|
|
||||||
{
|
|
||||||
TagName = r.TagName ?? "",
|
|
||||||
Name = r.Name ?? "",
|
|
||||||
Prerelease = r.Prerelease,
|
|
||||||
PublishedAt = r.PublishedAt,
|
|
||||||
Body = r.Body,
|
|
||||||
Assets = r.Assets?.Select(a => new ReleaseAsset
|
|
||||||
{
|
|
||||||
Name = a.Name ?? "",
|
|
||||||
BrowserDownloadUrl = a.BrowserDownloadUrl ?? "",
|
|
||||||
Size = a.Size
|
|
||||||
}).ToList() ?? []
|
|
||||||
}).ToList() ?? [];
|
|
||||||
}
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// 从 tag 解析版本号 (例如: v1.0.0 -> 1.0.0)
|
|
||||||
/// </summary>
|
|
||||||
private static string ParseVersionString(string tag)
|
|
||||||
{
|
|
||||||
return tag.TrimStart('v', 'V');
|
|
||||||
}
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// 解析版本号
|
|
||||||
/// </summary>
|
|
||||||
private static Version ParseVersion(string versionString)
|
|
||||||
{
|
|
||||||
var cleaned = ParseVersionString(versionString);
|
|
||||||
return Version.TryParse(cleaned, out var version) ? version : new Version(0, 0, 0);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// GitHub API 响应模型
|
|
||||||
internal sealed class GitHubRelease
|
|
||||||
{
|
|
||||||
[JsonPropertyName("tag_name")]
|
|
||||||
public string? TagName { get; set; }
|
|
||||||
|
|
||||||
[JsonPropertyName("name")]
|
|
||||||
public string? Name { get; set; }
|
|
||||||
|
|
||||||
[JsonPropertyName("prerelease")]
|
|
||||||
public bool Prerelease { get; set; }
|
|
||||||
|
|
||||||
[JsonPropertyName("published_at")]
|
|
||||||
public DateTime PublishedAt { get; set; }
|
|
||||||
|
|
||||||
[JsonPropertyName("body")]
|
|
||||||
public string? Body { get; set; }
|
|
||||||
|
|
||||||
[JsonPropertyName("assets")]
|
|
||||||
public List<GitHubAsset>? Assets { get; set; }
|
|
||||||
}
|
|
||||||
|
|
||||||
internal sealed class GitHubAsset
|
|
||||||
{
|
|
||||||
[JsonPropertyName("name")]
|
|
||||||
public string? Name { get; set; }
|
|
||||||
|
|
||||||
[JsonPropertyName("browser_download_url")]
|
|
||||||
public string? BrowserDownloadUrl { get; set; }
|
|
||||||
|
|
||||||
[JsonPropertyName("size")]
|
|
||||||
public long Size { get; set; }
|
|
||||||
}
|
|
||||||
File diff suppressed because it is too large
Load Diff
83
LanMountainDesktop.Launcher/Shell/AirAppRuntimeBridge.cs
Normal file
83
LanMountainDesktop.Launcher/Shell/AirAppRuntimeBridge.cs
Normal file
@@ -0,0 +1,83 @@
|
|||||||
|
using LanMountainDesktop.Shared.IPC;
|
||||||
|
using LanMountainDesktop.Shared.IPC.Abstractions.Services;
|
||||||
|
|
||||||
|
namespace LanMountainDesktop.Launcher.Shell;
|
||||||
|
|
||||||
|
internal sealed class AirAppRuntimeBridge
|
||||||
|
{
|
||||||
|
private const int ConnectAttempts = 8;
|
||||||
|
|
||||||
|
private readonly string _appRoot;
|
||||||
|
private readonly string? _dataRoot;
|
||||||
|
|
||||||
|
public AirAppRuntimeBridge(string appRoot, string? dataRoot)
|
||||||
|
{
|
||||||
|
_appRoot = appRoot;
|
||||||
|
_dataRoot = dataRoot;
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task EnsureStartedAsync()
|
||||||
|
{
|
||||||
|
if (await TryGetStatusAsync().ConfigureAwait(false) is not null)
|
||||||
|
{
|
||||||
|
Logger.Info("AirApp Runtime is already available.");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
var process = AirAppRuntimeProcessStarter.Start(new AirAppRuntimeStartRequest(
|
||||||
|
_appRoot,
|
||||||
|
Environment.ProcessId,
|
||||||
|
0,
|
||||||
|
_dataRoot));
|
||||||
|
Logger.Info($"AirApp Runtime start requested. Pid={(process is null ? -1 : process.Id)}; AppRoot='{_appRoot}'.");
|
||||||
|
|
||||||
|
for (var attempt = 1; attempt <= ConnectAttempts; attempt++)
|
||||||
|
{
|
||||||
|
if (await TryGetStatusAsync().ConfigureAwait(false) is not null)
|
||||||
|
{
|
||||||
|
Logger.Info("AirApp Runtime IPC is ready.");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
await Task.Delay(TimeSpan.FromMilliseconds(250 * attempt)).ConfigureAwait(false);
|
||||||
|
}
|
||||||
|
|
||||||
|
Logger.Warn("AirApp Runtime did not become ready after pre-start; Host fallback remains available.");
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task AttachHostAsync(int hostProcessId)
|
||||||
|
{
|
||||||
|
if (hostProcessId <= 0)
|
||||||
|
{
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try
|
||||||
|
{
|
||||||
|
using var client = new LanMountainDesktopIpcClient();
|
||||||
|
await client.ConnectAsync(IpcConstants.AirAppRuntimePipeName).ConfigureAwait(false);
|
||||||
|
var proxy = client.CreateProxy<IAirAppRuntimeControlService>();
|
||||||
|
var result = await proxy.AttachHostAsync(hostProcessId).ConfigureAwait(false);
|
||||||
|
Logger.Info($"AirApp Runtime host attach completed. Accepted={result.Accepted}; Code='{result.Code}'; HostPid={hostProcessId}.");
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
Logger.Warn($"Failed to attach Host to AirApp Runtime: {ex.Message}");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private static async Task<AirAppRuntimeStatus?> TryGetStatusAsync()
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
using var client = new LanMountainDesktopIpcClient();
|
||||||
|
await client.ConnectAsync(IpcConstants.AirAppRuntimePipeName).ConfigureAwait(false);
|
||||||
|
var proxy = client.CreateProxy<IAirAppRuntimeControlService>();
|
||||||
|
return await proxy.GetStatusAsync().ConfigureAwait(false);
|
||||||
|
}
|
||||||
|
catch
|
||||||
|
{
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,7 +1,7 @@
|
|||||||
using Avalonia.Threading;
|
using Avalonia.Threading;
|
||||||
using LanMountainDesktop.Launcher.Views;
|
using LanMountainDesktop.Launcher.Views;
|
||||||
|
|
||||||
namespace LanMountainDesktop.Launcher.Services;
|
namespace LanMountainDesktop.Launcher.Shell;
|
||||||
|
|
||||||
internal sealed class DeferredSplashStageReporter : ISplashStageReporter
|
internal sealed class DeferredSplashStageReporter : ISplashStageReporter
|
||||||
{
|
{
|
||||||
@@ -0,0 +1,30 @@
|
|||||||
|
using Avalonia.Controls.ApplicationLifetimes;
|
||||||
|
using LanMountainDesktop.Launcher.Views;
|
||||||
|
|
||||||
|
namespace LanMountainDesktop.Launcher.Shell.EntryHandlers;
|
||||||
|
|
||||||
|
internal static class LaunchEntryHandler
|
||||||
|
{
|
||||||
|
public static SplashWindow CreateSplashWindow()
|
||||||
|
{
|
||||||
|
var window = new SplashWindow();
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var appRoot = Commands.ResolveAppRoot(LauncherRuntimeContext.Current);
|
||||||
|
var versionInfo = new DeploymentLocator(appRoot).GetVersionInfo();
|
||||||
|
window.SetVersionInfo(versionInfo.Version, versionInfo.Codename);
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
Logger.Warn($"Failed to set splash version info: {ex.Message}");
|
||||||
|
}
|
||||||
|
|
||||||
|
return window;
|
||||||
|
}
|
||||||
|
|
||||||
|
public static Task RunAsync(
|
||||||
|
IClassicDesktopStyleApplicationLifetime desktop,
|
||||||
|
CommandContext context,
|
||||||
|
SplashWindow splashWindow) =>
|
||||||
|
LauncherCompositionRoot.RunOrchestratorWithSplashAsync(desktop, context, splashWindow);
|
||||||
|
}
|
||||||
@@ -0,0 +1,136 @@
|
|||||||
|
using Avalonia.Controls;
|
||||||
|
using Avalonia.Controls.ApplicationLifetimes;
|
||||||
|
using Avalonia.Threading;
|
||||||
|
using LanMountainDesktop.Launcher.Models;
|
||||||
|
using LanMountainDesktop.Launcher.Resources;
|
||||||
|
using LanMountainDesktop.Launcher.Views;
|
||||||
|
|
||||||
|
namespace LanMountainDesktop.Launcher.Shell.EntryHandlers;
|
||||||
|
|
||||||
|
internal static class PreviewEntryHandler
|
||||||
|
{
|
||||||
|
public static bool TryHandle(CommandContext context, IClassicDesktopStyleApplicationLifetime desktop)
|
||||||
|
{
|
||||||
|
switch (context.Command.ToLowerInvariant())
|
||||||
|
{
|
||||||
|
case "preview-splash":
|
||||||
|
RunSplashPreview(desktop);
|
||||||
|
return true;
|
||||||
|
case "preview-error":
|
||||||
|
RunErrorPreview(desktop);
|
||||||
|
return true;
|
||||||
|
case "preview-multi-instance":
|
||||||
|
RunMultiInstancePreview(desktop);
|
||||||
|
return true;
|
||||||
|
case "preview-update":
|
||||||
|
RunUpdatePreview(desktop);
|
||||||
|
return true;
|
||||||
|
case "preview-oobe":
|
||||||
|
RunOobePreview(desktop);
|
||||||
|
return true;
|
||||||
|
case "preview-debug":
|
||||||
|
new DevDebugWindow().Show();
|
||||||
|
return true;
|
||||||
|
default:
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private static void RunSplashPreview(IClassicDesktopStyleApplicationLifetime desktop)
|
||||||
|
{
|
||||||
|
var splashWindow = LaunchEntryHandler.CreateSplashWindow();
|
||||||
|
splashWindow.SetDebugMode(true);
|
||||||
|
splashWindow.Show();
|
||||||
|
_ = SimulateSplashPreviewAsync(desktop, splashWindow);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static void RunErrorPreview(IClassicDesktopStyleApplicationLifetime desktop)
|
||||||
|
{
|
||||||
|
var errorWindow = new ErrorWindow();
|
||||||
|
errorWindow.SetErrorMessage(Strings.Preview_ErrorMessage);
|
||||||
|
errorWindow.Show();
|
||||||
|
_ = WaitForWindowCloseAsync(desktop, errorWindow);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static void RunMultiInstancePreview(IClassicDesktopStyleApplicationLifetime desktop)
|
||||||
|
{
|
||||||
|
var promptWindow = new MultiInstancePromptWindow();
|
||||||
|
promptWindow.SetDetails(Environment.ProcessId, "ForegroundDesktop");
|
||||||
|
promptWindow.Show();
|
||||||
|
_ = WaitForWindowCloseAsync(desktop, promptWindow);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static void RunUpdatePreview(IClassicDesktopStyleApplicationLifetime desktop)
|
||||||
|
{
|
||||||
|
var updateWindow = new UpdateWindow();
|
||||||
|
updateWindow.SetDebugMode(true);
|
||||||
|
updateWindow.Show();
|
||||||
|
_ = SimulateUpdatePreviewAsync(desktop, updateWindow);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static void RunOobePreview(IClassicDesktopStyleApplicationLifetime desktop)
|
||||||
|
{
|
||||||
|
var oobeWindow = new OobeWindow();
|
||||||
|
oobeWindow.Show();
|
||||||
|
_ = SimulateOobePreviewAsync(desktop, oobeWindow);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static async Task SimulateSplashPreviewAsync(IClassicDesktopStyleApplicationLifetime desktop, SplashWindow window)
|
||||||
|
{
|
||||||
|
var stages = new[] { "initializing", "update", "plugins", "launch", "ready" };
|
||||||
|
var messages = new[]
|
||||||
|
{
|
||||||
|
Strings.Preview_SplashInitializing,
|
||||||
|
Strings.Preview_SplashCheckingUpdates,
|
||||||
|
Strings.Preview_SplashCheckingPlugins,
|
||||||
|
Strings.Preview_SplashLaunchingHost,
|
||||||
|
Strings.Preview_SplashReady
|
||||||
|
};
|
||||||
|
var reporter = (ISplashStageReporter)window;
|
||||||
|
|
||||||
|
for (var i = 0; i < stages.Length; i++)
|
||||||
|
{
|
||||||
|
reporter.Report(stages[i], messages[i]);
|
||||||
|
await Task.Delay(800).ConfigureAwait(false);
|
||||||
|
}
|
||||||
|
|
||||||
|
await Task.Delay(5000).ConfigureAwait(false);
|
||||||
|
await Dispatcher.UIThread.InvokeAsync(() => desktop.Shutdown(0));
|
||||||
|
}
|
||||||
|
|
||||||
|
private static async Task SimulateUpdatePreviewAsync(IClassicDesktopStyleApplicationLifetime desktop, UpdateWindow window)
|
||||||
|
{
|
||||||
|
var stages = new[] { "verify", "extract", "apply", "plugins", "cleanup" };
|
||||||
|
for (var i = 0; i < stages.Length; i++)
|
||||||
|
{
|
||||||
|
window.Report(stages[i], string.Format(Strings.Preview_UpdateProcessing, stages[i]), (i + 1) * 20);
|
||||||
|
await Task.Delay(600).ConfigureAwait(false);
|
||||||
|
}
|
||||||
|
|
||||||
|
window.ReportComplete(true, null);
|
||||||
|
await Task.Delay(3000).ConfigureAwait(false);
|
||||||
|
await Dispatcher.UIThread.InvokeAsync(() => desktop.Shutdown(0));
|
||||||
|
}
|
||||||
|
|
||||||
|
private static async Task SimulateOobePreviewAsync(IClassicDesktopStyleApplicationLifetime desktop, OobeWindow window)
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
await window.WaitForEnterAsync().ConfigureAwait(false);
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
Logger.Error("OOBE preview failed.", ex);
|
||||||
|
}
|
||||||
|
|
||||||
|
await Dispatcher.UIThread.InvokeAsync(() => desktop.Shutdown(0));
|
||||||
|
}
|
||||||
|
|
||||||
|
private static async Task WaitForWindowCloseAsync(IClassicDesktopStyleApplicationLifetime desktop, Window window)
|
||||||
|
{
|
||||||
|
var tcs = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously);
|
||||||
|
window.Closed += (_, _) => tcs.TrySetResult();
|
||||||
|
await tcs.Task.ConfigureAwait(false);
|
||||||
|
await Dispatcher.UIThread.InvokeAsync(() => desktop.Shutdown(0));
|
||||||
|
}
|
||||||
|
}
|
||||||
184
LanMountainDesktop.Launcher/Shell/LaunchUiPresenter.cs
Normal file
184
LanMountainDesktop.Launcher/Shell/LaunchUiPresenter.cs
Normal file
@@ -0,0 +1,184 @@
|
|||||||
|
using Avalonia.Threading;
|
||||||
|
using LanMountainDesktop.Launcher.Models;
|
||||||
|
using LanMountainDesktop.Launcher.Views;
|
||||||
|
using LanMountainDesktop.Shared.Contracts.Launcher;
|
||||||
|
using LanMountainDesktop.Shared.IPC.Abstractions.Services;
|
||||||
|
|
||||||
|
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
|
||||||
|
{
|
||||||
|
await Dispatcher.UIThread.InvokeAsync(() => splashWindow.DismissAsync());
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
Logger.Error("Failed to dismiss splash window.", ex);
|
||||||
|
}
|
||||||
|
|
||||||
|
await Dispatcher.UIThread.InvokeAsync(() =>
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
if (loadingDetailsWindow is not null && loadingDetailsWindow.IsVisible)
|
||||||
|
{
|
||||||
|
loadingDetailsWindow.Close();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
Logger.Error("Failed to close loading details window.", ex);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
public static async Task<(ErrorWindowResult Result, string? CustomPath)> ShowHostNotFoundErrorAsync()
|
||||||
|
{
|
||||||
|
ErrorWindow? errorWindow = null;
|
||||||
|
|
||||||
|
await Dispatcher.UIThread.InvokeAsync(() =>
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
errorWindow = new ErrorWindow();
|
||||||
|
errorWindow.ConfigureForHostNotFound();
|
||||||
|
errorWindow.SetErrorMessage("LanMountainDesktop host executable was not found.");
|
||||||
|
errorWindow.Show();
|
||||||
|
Logger.Warn("Host not found. Showing error window.");
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
Logger.Error("Failed to show host-not-found error window.", ex);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
if (errorWindow is null)
|
||||||
|
{
|
||||||
|
return (ErrorWindowResult.Exit, null);
|
||||||
|
}
|
||||||
|
|
||||||
|
ErrorWindowResult result;
|
||||||
|
string? customPath;
|
||||||
|
try
|
||||||
|
{
|
||||||
|
result = await errorWindow.WaitForChoiceAsync().ConfigureAwait(false);
|
||||||
|
customPath = errorWindow.GetCustomHostPath();
|
||||||
|
Logger.Info($"Host-not-found window result='{result}'; HasCustomPath={!string.IsNullOrWhiteSpace(customPath)}.");
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
Logger.Error("Failed while waiting for host-not-found window result.", ex);
|
||||||
|
result = ErrorWindowResult.Exit;
|
||||||
|
customPath = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
await Dispatcher.UIThread.InvokeAsync(() =>
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
if (errorWindow.IsVisible && errorWindow.IsLoaded)
|
||||||
|
{
|
||||||
|
errorWindow.Close();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
Logger.Error("Failed to close host-not-found error window.", ex);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
return (result, customPath);
|
||||||
|
}
|
||||||
|
|
||||||
|
public static async Task<MigrationResult> ShowMigrationPromptAsync(LegacyVersionInfo legacyInfo)
|
||||||
|
{
|
||||||
|
MigrationPromptWindow? migrationWindow = null;
|
||||||
|
|
||||||
|
await Dispatcher.UIThread.InvokeAsync(() =>
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
migrationWindow = new MigrationPromptWindow();
|
||||||
|
migrationWindow.SetLegacyInfo(legacyInfo);
|
||||||
|
migrationWindow.Show();
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
Logger.Error("Failed to show migration prompt window.", ex);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
if (migrationWindow is null)
|
||||||
|
{
|
||||||
|
return MigrationResult.Skipped;
|
||||||
|
}
|
||||||
|
|
||||||
|
MigrationResult result;
|
||||||
|
try
|
||||||
|
{
|
||||||
|
result = await migrationWindow.WaitForChoiceAsync().ConfigureAwait(false);
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
Logger.Error("Failed while waiting for migration prompt result.", ex);
|
||||||
|
result = MigrationResult.Skipped;
|
||||||
|
}
|
||||||
|
|
||||||
|
await Dispatcher.UIThread.InvokeAsync(() =>
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
if (migrationWindow.IsVisible && migrationWindow.IsLoaded)
|
||||||
|
{
|
||||||
|
migrationWindow.Close();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
Logger.Error("Failed to close migration prompt window.", ex);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
public static string MapStartupStageToSplashStage(StartupStage stage) => stage switch
|
||||||
|
{
|
||||||
|
StartupStage.Initializing => "initializing",
|
||||||
|
StartupStage.LoadingSettings => "settings",
|
||||||
|
StartupStage.LoadingPlugins => "plugins",
|
||||||
|
StartupStage.TrayReady => "shell",
|
||||||
|
StartupStage.InitializingUI => "ui",
|
||||||
|
StartupStage.ShellInitialized => "shell",
|
||||||
|
StartupStage.BackgroundReady => "ready",
|
||||||
|
StartupStage.DesktopVisible => "ready",
|
||||||
|
StartupStage.ActivationRedirected => "activation",
|
||||||
|
StartupStage.ActivationFailed => "error",
|
||||||
|
StartupStage.Ready => "ready",
|
||||||
|
_ => "launch"
|
||||||
|
};
|
||||||
|
|
||||||
|
public static async Task<MultiInstancePromptResult> ShowMultiInstancePromptAsync(PublicShellStatus status)
|
||||||
|
{
|
||||||
|
return await Dispatcher.UIThread.InvokeAsync(async () =>
|
||||||
|
{
|
||||||
|
var prompt = new MultiInstancePromptWindow();
|
||||||
|
prompt.SetDetails(status.ProcessId, status.ShellState);
|
||||||
|
prompt.Show();
|
||||||
|
return await prompt.WaitForChoiceAsync().ConfigureAwait(true);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,6 +1,6 @@
|
|||||||
using Avalonia.Media.Imaging;
|
using Avalonia.Media.Imaging;
|
||||||
|
|
||||||
namespace LanMountainDesktop.Launcher.Services;
|
namespace LanMountainDesktop.Launcher.Shell;
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// 启动器背景图片服务
|
/// 启动器背景图片服务
|
||||||
26
LanMountainDesktop.Launcher/Shell/LauncherCompositionRoot.cs
Normal file
26
LanMountainDesktop.Launcher/Shell/LauncherCompositionRoot.cs
Normal file
@@ -0,0 +1,26 @@
|
|||||||
|
using Avalonia.Controls.ApplicationLifetimes;
|
||||||
|
using LanMountainDesktop.Launcher.Views;
|
||||||
|
|
||||||
|
namespace LanMountainDesktop.Launcher.Shell;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Launcher GUI composition root. It only wires services and dispatches to entry coordinators.
|
||||||
|
/// </summary>
|
||||||
|
internal static class LauncherCompositionRoot
|
||||||
|
{
|
||||||
|
public static LauncherOrchestrator CreateOrchestrator(
|
||||||
|
CommandContext context,
|
||||||
|
string appRoot,
|
||||||
|
StartupAttemptRegistry startupAttemptRegistry,
|
||||||
|
LauncherCoordinatorIpcServer coordinatorServer)
|
||||||
|
{
|
||||||
|
_ = appRoot;
|
||||||
|
return LauncherServiceRegistration.CreateOrchestrator(context, startupAttemptRegistry, coordinatorServer);
|
||||||
|
}
|
||||||
|
|
||||||
|
public static Task RunOrchestratorWithSplashAsync(
|
||||||
|
IClassicDesktopStyleApplicationLifetime desktop,
|
||||||
|
CommandContext context,
|
||||||
|
SplashWindow splashWindow) =>
|
||||||
|
LauncherGuiCoordinator.RunAsync(desktop, context, splashWindow);
|
||||||
|
}
|
||||||
526
LanMountainDesktop.Launcher/Shell/LauncherGuiCoordinator.cs
Normal file
526
LanMountainDesktop.Launcher/Shell/LauncherGuiCoordinator.cs
Normal file
@@ -0,0 +1,526 @@
|
|||||||
|
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);
|
||||||
|
var airAppRuntimeBridge = new AirAppRuntimeBridge(appRoot, dataLocationResolver.ResolveDataRoot());
|
||||||
|
await airAppRuntimeBridge.EnsureStartedAsync().ConfigureAwait(false);
|
||||||
|
|
||||||
|
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 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 airAppRuntimeBridge.AttachHostAsync(hostPid).ConfigureAwait(false);
|
||||||
|
await WaitForHostProcessToExitAsync(hostPid).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 WaitForHostProcessToExitAsync(int hostPid)
|
||||||
|
{
|
||||||
|
Logger.Info($"Launcher entering host background lifetime. HostPid={hostPid}.");
|
||||||
|
while (TryGetLiveProcess(hostPid))
|
||||||
|
{
|
||||||
|
await Task.Delay(TimeSpan.FromSeconds(2)).ConfigureAwait(false);
|
||||||
|
}
|
||||||
|
|
||||||
|
Logger.Info("Launcher host background lifetime completed; host process is gone.");
|
||||||
|
}
|
||||||
|
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
278
LanMountainDesktop.Launcher/Shell/LauncherOrchestrator.cs
Normal file
278
LanMountainDesktop.Launcher/Shell/LauncherOrchestrator.cs
Normal file
@@ -0,0 +1,278 @@
|
|||||||
|
using Avalonia.Threading;
|
||||||
|
using LanMountainDesktop.Launcher.Models;
|
||||||
|
using LanMountainDesktop.Launcher.Startup;
|
||||||
|
using LanMountainDesktop.Launcher.Views;
|
||||||
|
using LanMountainDesktop.Shared.Contracts.Launcher;
|
||||||
|
using LanMountainDesktop.Shared.IPC;
|
||||||
|
using LanMountainDesktop.Shared.IPC.Abstractions.Services;
|
||||||
|
|
||||||
|
namespace LanMountainDesktop.Launcher.Shell;
|
||||||
|
|
||||||
|
internal sealed class LauncherOrchestrator
|
||||||
|
{
|
||||||
|
private readonly CommandContext _context;
|
||||||
|
private readonly DeploymentLocator _deploymentLocator;
|
||||||
|
private readonly OobeStateService _oobeStateService;
|
||||||
|
private readonly StartupAttemptRegistry _startupAttemptRegistry;
|
||||||
|
private readonly LauncherCoordinatorIpcServer? _coordinatorIpcServer;
|
||||||
|
private readonly DataLocationResolver _dataLocationResolver;
|
||||||
|
private readonly IReadOnlyList<IOobeStep> _oobeSteps;
|
||||||
|
private readonly LaunchPipeline _pipeline;
|
||||||
|
|
||||||
|
public LauncherOrchestrator(
|
||||||
|
CommandContext context,
|
||||||
|
DeploymentLocator deploymentLocator,
|
||||||
|
OobeStateService oobeStateService,
|
||||||
|
StartupAttemptRegistry startupAttemptRegistry,
|
||||||
|
LauncherCoordinatorIpcServer? coordinatorIpcServer = null,
|
||||||
|
LaunchPipeline? pipeline = null)
|
||||||
|
{
|
||||||
|
_context = context;
|
||||||
|
_deploymentLocator = deploymentLocator;
|
||||||
|
_oobeStateService = oobeStateService;
|
||||||
|
_startupAttemptRegistry = startupAttemptRegistry;
|
||||||
|
_coordinatorIpcServer = coordinatorIpcServer;
|
||||||
|
_dataLocationResolver = new DataLocationResolver(deploymentLocator.GetAppRoot());
|
||||||
|
_oobeSteps =
|
||||||
|
[
|
||||||
|
new WelcomeOobeStep(_oobeStateService, _context),
|
||||||
|
new DataLocationOobeStep(_dataLocationResolver)
|
||||||
|
];
|
||||||
|
_pipeline = pipeline ?? new LaunchPipeline(
|
||||||
|
[
|
||||||
|
new CleanupDeploymentsPhase(),
|
||||||
|
new ExistingHostProbePhase(),
|
||||||
|
new OobeGatePhase(),
|
||||||
|
new LaunchHostPhase(),
|
||||||
|
new MonitorStartupPhase()
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
public static string ResolveSuccessPolicyKey(CommandContext context) =>
|
||||||
|
new StartupSuccessTracker(context).PolicyKey;
|
||||||
|
|
||||||
|
public async Task<LauncherResult> RunAsync(SplashWindow? existingSplashWindow = null)
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var oobeDecision = _oobeStateService.Evaluate(_context);
|
||||||
|
if (oobeDecision.ShouldShowOobe)
|
||||||
|
{
|
||||||
|
var legacyInfo = LegacyVersionDetector.DetectLegacyInstallation();
|
||||||
|
if (legacyInfo is not null)
|
||||||
|
{
|
||||||
|
var migrationResult = await LaunchUiPresenter.ShowMigrationPromptAsync(legacyInfo).ConfigureAwait(false);
|
||||||
|
Logger.Info($"Migration prompt completed. Result='{migrationResult}'.");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
var splashWindow = existingSplashWindow ?? await Dispatcher.UIThread.InvokeAsync(() =>
|
||||||
|
{
|
||||||
|
var window = new SplashWindow();
|
||||||
|
window.Show();
|
||||||
|
return window;
|
||||||
|
});
|
||||||
|
var versionInfo = _deploymentLocator.GetVersionInfo();
|
||||||
|
splashWindow.SetVersionInfo(versionInfo.Version, versionInfo.Codename);
|
||||||
|
var reporter = (ISplashStageReporter)splashWindow;
|
||||||
|
|
||||||
|
LoadingDetailsWindow? loadingDetailsWindow = null;
|
||||||
|
if (_context.IsDebugMode || _context.GetOption("show-loading-details") == "true")
|
||||||
|
{
|
||||||
|
await Dispatcher.UIThread.InvokeAsync(() =>
|
||||||
|
{
|
||||||
|
loadingDetailsWindow = new LoadingDetailsWindow();
|
||||||
|
loadingDetailsWindow.Show();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
var successTcs = new TaskCompletionSource<StartupSuccessState>(TaskCreationOptions.RunContinuationsAsynchronously);
|
||||||
|
var activationFailedTcs = new TaskCompletionSource<string>(TaskCreationOptions.RunContinuationsAsynchronously);
|
||||||
|
var lastStage = StartupStage.Initializing;
|
||||||
|
var lastStageMessage = "launcher-started";
|
||||||
|
var startupSuccessTracker = new StartupSuccessTracker(_context);
|
||||||
|
var activationFailureReason = string.Empty;
|
||||||
|
var ipcConnected = false;
|
||||||
|
var softTimeoutShown = false;
|
||||||
|
var attachedToExistingAttempt = false;
|
||||||
|
var windowsClosingByOrchestrator = false;
|
||||||
|
StartupAttemptRecord? trackedAttempt = null;
|
||||||
|
PublicShellStatus? shellStatus = null;
|
||||||
|
var loadingState = new LoadingStateMessage();
|
||||||
|
|
||||||
|
void PublishCoordinatorStatus(bool? hostProcessAliveOverride = null, bool completed = false, bool succeeded = false)
|
||||||
|
{
|
||||||
|
if (_coordinatorIpcServer is null)
|
||||||
|
{
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
trackedAttempt = _startupAttemptRegistry.GetOwnedAttempt() ?? trackedAttempt;
|
||||||
|
var hostPid = trackedAttempt?.HostPid ?? 0;
|
||||||
|
var hostProcessAlive = hostProcessAliveOverride ??
|
||||||
|
(hostPid > 0 && LaunchResultBuilder.TryGetLiveProcess(hostPid, out _));
|
||||||
|
var status = new LauncherCoordinatorStatus
|
||||||
|
{
|
||||||
|
AttemptId = trackedAttempt?.AttemptId ?? string.Empty,
|
||||||
|
CoordinatorPid = Environment.ProcessId,
|
||||||
|
HostPid = hostPid,
|
||||||
|
HostProcessAlive = hostProcessAlive,
|
||||||
|
LaunchSource = trackedAttempt?.LaunchSource ?? _context.LaunchSource,
|
||||||
|
SuccessPolicy = trackedAttempt?.SuccessPolicy ?? startupSuccessTracker.PolicyKey,
|
||||||
|
LastObservedStage = lastStage,
|
||||||
|
LastObservedMessage = lastStageMessage,
|
||||||
|
PublicIpcConnected = ipcConnected,
|
||||||
|
State = trackedAttempt?.State.ToString() ?? StartupAttemptState.Pending.ToString(),
|
||||||
|
SoftTimeoutShown = softTimeoutShown,
|
||||||
|
Completed = completed,
|
||||||
|
Succeeded = succeeded,
|
||||||
|
ShellStatus = shellStatus,
|
||||||
|
UpdatedAtUtc = DateTimeOffset.UtcNow
|
||||||
|
};
|
||||||
|
|
||||||
|
_coordinatorIpcServer.UpdateStatus(status);
|
||||||
|
_startupAttemptRegistry.UpdateOwnedCoordinatorHeartbeat(status);
|
||||||
|
}
|
||||||
|
|
||||||
|
trackedAttempt = _startupAttemptRegistry.GetOwnedAttempt();
|
||||||
|
PublishCoordinatorStatus();
|
||||||
|
|
||||||
|
EventHandler? splashClosedHandler = null;
|
||||||
|
splashClosedHandler = (_, _) =>
|
||||||
|
{
|
||||||
|
if (windowsClosingByOrchestrator)
|
||||||
|
{
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
_startupAttemptRegistry.MarkOwnedDetachedWaiting();
|
||||||
|
Logger.Warn("Splash window was closed manually. Launcher will continue monitoring the current startup attempt.");
|
||||||
|
};
|
||||||
|
splashWindow.Closed += splashClosedHandler;
|
||||||
|
|
||||||
|
using var ipcClient = new LanMountainDesktopIpcClient();
|
||||||
|
ipcClient.RegisterNotifyHandler<StartupProgressMessage>(IpcRoutedNotifyIds.LauncherStartupProgress, message =>
|
||||||
|
{
|
||||||
|
Dispatcher.UIThread.Post(() =>
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
ipcConnected = true;
|
||||||
|
lastStage = message.Stage;
|
||||||
|
lastStageMessage = message.Message ?? message.Stage.ToString();
|
||||||
|
Logger.Info($"IPC stage received. Stage='{message.Stage}'; Message='{message.Message ?? string.Empty}'.");
|
||||||
|
|
||||||
|
loadingState = loadingState with
|
||||||
|
{
|
||||||
|
Stage = message.Stage,
|
||||||
|
OverallProgressPercent = message.ProgressPercent,
|
||||||
|
Message = message.Message,
|
||||||
|
Timestamp = DateTimeOffset.UtcNow
|
||||||
|
};
|
||||||
|
|
||||||
|
reporter.Report(LaunchUiPresenter.MapStartupStageToSplashStage(message.Stage), message.Message ?? message.Stage.ToString());
|
||||||
|
loadingDetailsWindow?.UpdateLoadingState(loadingState);
|
||||||
|
_startupAttemptRegistry.UpdateOwnedStage(message.Stage, message.Message, ipcConnected: true);
|
||||||
|
PublishCoordinatorStatus();
|
||||||
|
|
||||||
|
if (startupSuccessTracker.TryResolve(message.Stage, out var successState))
|
||||||
|
{
|
||||||
|
successTcs.TrySetResult(successState);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (message.Stage == StartupStage.ActivationFailed)
|
||||||
|
{
|
||||||
|
activationFailureReason = message.Message ?? "activation_failed";
|
||||||
|
activationFailedTcs.TrySetResult(message.Message ?? "activation_failed");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
Logger.Error("IPC progress callback failed.", ex);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
ipcClient.RegisterNotifyHandler<LoadingStateMessage>(IpcRoutedNotifyIds.LauncherLoadingState, message =>
|
||||||
|
{
|
||||||
|
Dispatcher.UIThread.Post(() =>
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
loadingState = message;
|
||||||
|
loadingDetailsWindow?.UpdateLoadingState(loadingState);
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
Logger.Error("IPC loading-state callback failed.", ex);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
var launchContext = new LaunchContext
|
||||||
|
{
|
||||||
|
CommandContext = _context,
|
||||||
|
DeploymentLocator = _deploymentLocator,
|
||||||
|
OobeStateService = _oobeStateService,
|
||||||
|
StartupAttemptRegistry = _startupAttemptRegistry,
|
||||||
|
CoordinatorIpcServer = _coordinatorIpcServer,
|
||||||
|
DataLocationResolver = _dataLocationResolver,
|
||||||
|
OobeSteps = _oobeSteps,
|
||||||
|
SplashWindow = splashWindow,
|
||||||
|
LoadingDetailsWindow = loadingDetailsWindow,
|
||||||
|
Reporter = reporter,
|
||||||
|
IpcClient = ipcClient,
|
||||||
|
SuccessTracker = startupSuccessTracker,
|
||||||
|
SuccessTcs = successTcs,
|
||||||
|
ActivationFailedTcs = activationFailedTcs,
|
||||||
|
LoadingState = loadingState,
|
||||||
|
PublishCoordinatorStatus = PublishCoordinatorStatus,
|
||||||
|
SplashClosedHandler = splashClosedHandler
|
||||||
|
};
|
||||||
|
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var result = await _pipeline.ExecuteAsync(launchContext).ConfigureAwait(false);
|
||||||
|
windowsClosingByOrchestrator = launchContext.WindowsClosingByOrchestrator;
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
finally
|
||||||
|
{
|
||||||
|
if (splashClosedHandler is not null)
|
||||||
|
{
|
||||||
|
splashWindow.Closed -= splashClosedHandler;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!windowsClosingByOrchestrator && !launchContext.WindowsClosingByOrchestrator)
|
||||||
|
{
|
||||||
|
await Dispatcher.UIThread.InvokeAsync(() =>
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
if (splashWindow.IsVisible && splashWindow.IsLoaded)
|
||||||
|
{
|
||||||
|
splashWindow.Close();
|
||||||
|
Logger.Info("Splash window closed in orchestrator cleanup.");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
Logger.Error("Failed to close splash window during orchestrator cleanup.", ex);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
Logger.Error("Launcher orchestrator failed.", ex);
|
||||||
|
var oobeDecision = _oobeStateService.Evaluate(_context);
|
||||||
|
return LaunchResultBuilder.Build(
|
||||||
|
false,
|
||||||
|
"launch",
|
||||||
|
"exception",
|
||||||
|
ex.Message,
|
||||||
|
LaunchResultBuilder.BuildLauncherContextDetails(_context, oobeDecision, _deploymentLocator.GetAppRoot()),
|
||||||
|
ex.ToString());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,52 @@
|
|||||||
|
using Microsoft.Extensions.DependencyInjection;
|
||||||
|
|
||||||
|
namespace LanMountainDesktop.Launcher.Shell;
|
||||||
|
|
||||||
|
internal static class LauncherServiceRegistration
|
||||||
|
{
|
||||||
|
private static ServiceProvider? _provider;
|
||||||
|
|
||||||
|
public static IServiceProvider Provider =>
|
||||||
|
_provider ?? throw new InvalidOperationException("Launcher services are not initialized.");
|
||||||
|
|
||||||
|
public static void Initialize(CommandContext context)
|
||||||
|
{
|
||||||
|
if (_provider is not null)
|
||||||
|
{
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
var appRoot = Commands.ResolveAppRoot(context);
|
||||||
|
var services = new ServiceCollection();
|
||||||
|
services.AddSingleton(context);
|
||||||
|
services.AddSingleton(new DeploymentLocator(appRoot));
|
||||||
|
services.AddSingleton(sp => new OobeStateService(appRoot));
|
||||||
|
services.AddSingleton(sp => new DataLocationResolver(appRoot));
|
||||||
|
services.AddSingleton<HostLaunchService>();
|
||||||
|
services.AddSingleton<StartupAttemptRegistry>();
|
||||||
|
services.AddSingleton<ILaunchPhase, CleanupDeploymentsPhase>();
|
||||||
|
services.AddSingleton<ILaunchPhase, ExistingHostProbePhase>();
|
||||||
|
services.AddSingleton<ILaunchPhase, OobeGatePhase>();
|
||||||
|
services.AddSingleton<ILaunchPhase, LaunchHostPhase>();
|
||||||
|
services.AddSingleton<ILaunchPhase, MonitorStartupPhase>();
|
||||||
|
services.AddSingleton(sp => new LaunchPipeline(sp.GetServices<ILaunchPhase>()));
|
||||||
|
|
||||||
|
_provider = services.BuildServiceProvider();
|
||||||
|
}
|
||||||
|
|
||||||
|
public static LauncherOrchestrator CreateOrchestrator(
|
||||||
|
CommandContext context,
|
||||||
|
StartupAttemptRegistry startupAttemptRegistry,
|
||||||
|
LauncherCoordinatorIpcServer coordinatorServer)
|
||||||
|
{
|
||||||
|
Initialize(context);
|
||||||
|
var services = Provider;
|
||||||
|
return new LauncherOrchestrator(
|
||||||
|
context,
|
||||||
|
services.GetRequiredService<DeploymentLocator>(),
|
||||||
|
services.GetRequiredService<OobeStateService>(),
|
||||||
|
startupAttemptRegistry,
|
||||||
|
coordinatorServer,
|
||||||
|
services.GetRequiredService<LaunchPipeline>());
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -2,7 +2,7 @@ using Avalonia;
|
|||||||
using Avalonia.Styling;
|
using Avalonia.Styling;
|
||||||
using FluentAvalonia.Styling;
|
using FluentAvalonia.Styling;
|
||||||
|
|
||||||
namespace LanMountainDesktop.Launcher.Services;
|
namespace LanMountainDesktop.Launcher.Shell;
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// 主题服务,管理启动器的主题设置
|
/// 主题服务,管理启动器的主题设置
|
||||||
163
LanMountainDesktop.Launcher/Startup/ExistingHostProbe.cs
Normal file
163
LanMountainDesktop.Launcher/Startup/ExistingHostProbe.cs
Normal file
@@ -0,0 +1,163 @@
|
|||||||
|
using LanMountainDesktop.Launcher.Shell;
|
||||||
|
using LanMountainDesktop.Launcher.Views;
|
||||||
|
using LanMountainDesktop.Shared.Contracts.Launcher;
|
||||||
|
using LanMountainDesktop.Shared.IPC;
|
||||||
|
using LanMountainDesktop.Shared.IPC.Abstractions.Services;
|
||||||
|
|
||||||
|
namespace LanMountainDesktop.Launcher.Startup;
|
||||||
|
|
||||||
|
internal static class ExistingHostProbe
|
||||||
|
{
|
||||||
|
public static MultiInstanceLaunchBehavior LoadMultiInstanceLaunchBehavior(DataLocationResolver dataLocationResolver)
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var settingsPath = HostAppSettingsOobeMerger.GetSettingsFilePath(dataLocationResolver.ResolveDataRoot());
|
||||||
|
return HostAppSettingsOobeMerger.LoadMultiInstanceLaunchBehavior(settingsPath);
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
Logger.Warn($"Failed to load multi-instance launch behavior. Falling back to default. {ex.Message}");
|
||||||
|
return MultiInstanceLaunchBehavior.NotifyAndOpenDesktop;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public static async Task<PublicShellStatus?> TryGetExistingHostStatusAsync(
|
||||||
|
LanMountainDesktopIpcClient ipcClient,
|
||||||
|
TimeSpan timeout)
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var connected = ipcClient.IsConnected ||
|
||||||
|
await PublicIpcConnection.TryConnectAsync(ipcClient, timeout).ConfigureAwait(false);
|
||||||
|
if (!connected)
|
||||||
|
{
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
var shellProxy = ipcClient.CreateProxy<IPublicShellControlService>();
|
||||||
|
return await shellProxy.GetShellStatusAsync().ConfigureAwait(false);
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
Logger.Info($"Existing host status probe did not complete: {ex.Message}");
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public static async Task<ExistingHostBehaviorResult> ApplyExistingHostBehaviorAsync(
|
||||||
|
LanMountainDesktopIpcClient ipcClient,
|
||||||
|
MultiInstanceLaunchBehavior behavior,
|
||||||
|
PublicShellStatus status)
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var shellProxy = ipcClient.CreateProxy<IPublicShellControlService>();
|
||||||
|
return behavior switch
|
||||||
|
{
|
||||||
|
MultiInstanceLaunchBehavior.OpenDesktopSilently => await ActivateExistingHostForBehaviorAsync(
|
||||||
|
shellProxy,
|
||||||
|
showLauncherNotice: false,
|
||||||
|
successCode: "existing_host_activated",
|
||||||
|
successMessage: "Launcher activated the existing desktop instance.",
|
||||||
|
failureCode: "existing_host_activation_failed").ConfigureAwait(false),
|
||||||
|
|
||||||
|
MultiInstanceLaunchBehavior.NotifyAndOpenDesktop => await ActivateExistingHostForBehaviorAsync(
|
||||||
|
shellProxy,
|
||||||
|
showLauncherNotice: true,
|
||||||
|
successCode: "existing_host_activated_with_notice",
|
||||||
|
successMessage: "Launcher activated the existing desktop instance and showed the repeated-launch notice.",
|
||||||
|
failureCode: "existing_host_activation_failed").ConfigureAwait(false),
|
||||||
|
|
||||||
|
MultiInstanceLaunchBehavior.PromptOnly => await ShowPromptOnlyExistingHostAsync(
|
||||||
|
shellProxy,
|
||||||
|
status).ConfigureAwait(false),
|
||||||
|
|
||||||
|
MultiInstanceLaunchBehavior.RestartApp => await RestartExistingHostAsync(shellProxy).ConfigureAwait(false),
|
||||||
|
|
||||||
|
_ => await ActivateExistingHostForBehaviorAsync(
|
||||||
|
shellProxy,
|
||||||
|
showLauncherNotice: true,
|
||||||
|
successCode: "existing_host_activated_with_notice",
|
||||||
|
successMessage: "Launcher activated the existing desktop instance and showed the repeated-launch notice.",
|
||||||
|
failureCode: "existing_host_activation_failed").ConfigureAwait(false)
|
||||||
|
};
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
Logger.Warn($"Failed to apply multi-instance behavior '{behavior}': {ex.Message}");
|
||||||
|
return new ExistingHostBehaviorResult(
|
||||||
|
false,
|
||||||
|
"multi_instance_behavior_failed",
|
||||||
|
$"Failed to apply multi-instance behavior '{behavior}': {ex.Message}",
|
||||||
|
null);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private static async Task<ExistingHostBehaviorResult> ActivateExistingHostForBehaviorAsync(
|
||||||
|
IPublicShellControlService shellProxy,
|
||||||
|
bool showLauncherNotice,
|
||||||
|
string successCode,
|
||||||
|
string successMessage,
|
||||||
|
string failureCode)
|
||||||
|
{
|
||||||
|
var activation = await shellProxy.ActivateMainWindowWithStatusAsync().ConfigureAwait(false);
|
||||||
|
var success = activation.Accepted || HostActivationPolicy.IsRecoverableActivationFailure(activation);
|
||||||
|
if (showLauncherNotice && success)
|
||||||
|
{
|
||||||
|
var promptResult = await LaunchUiPresenter.ShowMultiInstancePromptAsync(activation.Status).ConfigureAwait(false);
|
||||||
|
if (promptResult == MultiInstancePromptResult.OpenDesktop)
|
||||||
|
{
|
||||||
|
activation = await shellProxy.ActivateMainWindowWithStatusAsync().ConfigureAwait(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return new ExistingHostBehaviorResult(
|
||||||
|
success,
|
||||||
|
activation.Accepted ? successCode : success ? "existing_host_startup_pending" : failureCode,
|
||||||
|
activation.Accepted ? successMessage : activation.Message,
|
||||||
|
activation);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static async Task<ExistingHostBehaviorResult> RestartExistingHostAsync(
|
||||||
|
IPublicShellControlService shellProxy)
|
||||||
|
{
|
||||||
|
var accepted = await shellProxy.RestartAsync().ConfigureAwait(false);
|
||||||
|
return new ExistingHostBehaviorResult(
|
||||||
|
accepted,
|
||||||
|
accepted ? "existing_host_restart_requested" : "existing_host_restart_failed",
|
||||||
|
accepted
|
||||||
|
? "Launcher requested the existing desktop instance to restart."
|
||||||
|
: "Launcher could not request restart from the existing desktop instance.",
|
||||||
|
null);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static async Task<ExistingHostBehaviorResult> ShowPromptOnlyExistingHostAsync(
|
||||||
|
IPublicShellControlService shellProxy,
|
||||||
|
PublicShellStatus status)
|
||||||
|
{
|
||||||
|
var promptResult = await LaunchUiPresenter.ShowMultiInstancePromptAsync(status).ConfigureAwait(false);
|
||||||
|
|
||||||
|
if (promptResult == MultiInstancePromptResult.OpenDesktop)
|
||||||
|
{
|
||||||
|
return await ActivateExistingHostForBehaviorAsync(
|
||||||
|
shellProxy,
|
||||||
|
showLauncherNotice: false,
|
||||||
|
successCode: "existing_host_activated_from_prompt",
|
||||||
|
successMessage: "Launcher activated the existing desktop instance from the prompt.",
|
||||||
|
failureCode: "existing_host_activation_failed").ConfigureAwait(false);
|
||||||
|
}
|
||||||
|
|
||||||
|
return new ExistingHostBehaviorResult(
|
||||||
|
true,
|
||||||
|
"existing_host_prompt_only",
|
||||||
|
"Launcher showed the repeated-launch prompt and did not open the desktop automatically.",
|
||||||
|
null);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
internal sealed record ExistingHostBehaviorResult(
|
||||||
|
bool Success,
|
||||||
|
string Code,
|
||||||
|
string Message,
|
||||||
|
PublicShellActivationResult? ActivationResult);
|
||||||
50
LanMountainDesktop.Launcher/Startup/HostActivationPolicy.cs
Normal file
50
LanMountainDesktop.Launcher/Startup/HostActivationPolicy.cs
Normal file
@@ -0,0 +1,50 @@
|
|||||||
|
using LanMountainDesktop.Shared.Contracts.Launcher;
|
||||||
|
using LanMountainDesktop.Shared.IPC.Abstractions.Services;
|
||||||
|
|
||||||
|
namespace LanMountainDesktop.Launcher.Startup;
|
||||||
|
|
||||||
|
internal static class HostActivationPolicy
|
||||||
|
{
|
||||||
|
internal static bool ShouldProbeExistingHostBeforeLaunch(CommandContext context)
|
||||||
|
{
|
||||||
|
if (!string.Equals(context.Command, "launch", StringComparison.OrdinalIgnoreCase))
|
||||||
|
{
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (context.IsPreviewCommand || context.IsMaintenanceCommand)
|
||||||
|
{
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
return !string.Equals(context.LaunchSource, "restart", StringComparison.OrdinalIgnoreCase);
|
||||||
|
}
|
||||||
|
|
||||||
|
internal static bool IsExistingHostReadyForLauncherDecision(PublicShellStatus? status) =>
|
||||||
|
status is { PublicIpcReady: true, ProcessId: > 0 };
|
||||||
|
|
||||||
|
internal static bool IsRecoverableActivationFailure(PublicShellActivationResult activation)
|
||||||
|
{
|
||||||
|
if (activation.Accepted)
|
||||||
|
{
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (string.Equals(activation.Code, "shutdown_in_progress", StringComparison.OrdinalIgnoreCase))
|
||||||
|
{
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
return activation.Status.PublicIpcReady &&
|
||||||
|
(!activation.Status.MainWindowOpened ||
|
||||||
|
!activation.Status.DesktopVisible ||
|
||||||
|
string.Equals(activation.Code, "shell_not_ready", StringComparison.OrdinalIgnoreCase) ||
|
||||||
|
string.Equals(activation.Code, "startup_pending", StringComparison.OrdinalIgnoreCase));
|
||||||
|
}
|
||||||
|
|
||||||
|
internal static bool IsSuccessfulActivationExitCode(int exitCode) =>
|
||||||
|
exitCode == HostExitCodes.SecondaryActivationSucceeded;
|
||||||
|
|
||||||
|
internal static bool IsFailedActivationExitCode(int exitCode) =>
|
||||||
|
exitCode is HostExitCodes.SecondaryActivationFailed or HostExitCodes.RestartLockNotAcquired;
|
||||||
|
}
|
||||||
77
LanMountainDesktop.Launcher/Startup/HostLaunchModels.cs
Normal file
77
LanMountainDesktop.Launcher/Startup/HostLaunchModels.cs
Normal file
@@ -0,0 +1,77 @@
|
|||||||
|
using System.Diagnostics;
|
||||||
|
using LanMountainDesktop.Launcher.Models;
|
||||||
|
using LanMountainDesktop.Shared.Contracts.Launcher;
|
||||||
|
|
||||||
|
namespace LanMountainDesktop.Launcher.Startup;
|
||||||
|
|
||||||
|
internal enum HostStartMode
|
||||||
|
{
|
||||||
|
ShellExecute,
|
||||||
|
Direct
|
||||||
|
}
|
||||||
|
|
||||||
|
internal sealed record HostStartAttempt(
|
||||||
|
HostStartMode StartMode,
|
||||||
|
bool ProcessCreated,
|
||||||
|
Process? Process,
|
||||||
|
bool ExitedEarly,
|
||||||
|
int? ExitCode,
|
||||||
|
string? FailureReason,
|
||||||
|
string? PackageRoot,
|
||||||
|
string? WorkingDirectory,
|
||||||
|
string? Arguments)
|
||||||
|
{
|
||||||
|
public int? ProcessId => Process?.Id;
|
||||||
|
|
||||||
|
public static HostStartAttempt Started(HostStartMode startMode, Process process, HostLaunchPlan plan) =>
|
||||||
|
new(
|
||||||
|
startMode,
|
||||||
|
true,
|
||||||
|
process,
|
||||||
|
false,
|
||||||
|
null,
|
||||||
|
null,
|
||||||
|
plan.PackageRoot,
|
||||||
|
plan.WorkingDirectory,
|
||||||
|
HostLaunchPlanBuilder.FormatArgumentsForLog(plan.Arguments));
|
||||||
|
|
||||||
|
public static HostStartAttempt EarlyExit(HostStartMode startMode, Process process, int exitCode, HostLaunchPlan plan) =>
|
||||||
|
new(
|
||||||
|
startMode,
|
||||||
|
true,
|
||||||
|
process,
|
||||||
|
true,
|
||||||
|
exitCode,
|
||||||
|
null,
|
||||||
|
plan.PackageRoot,
|
||||||
|
plan.WorkingDirectory,
|
||||||
|
HostLaunchPlanBuilder.FormatArgumentsForLog(plan.Arguments));
|
||||||
|
|
||||||
|
public static HostStartAttempt StartFailed(HostStartMode startMode, string failureReason, HostLaunchPlan? plan = null) =>
|
||||||
|
new(
|
||||||
|
startMode,
|
||||||
|
false,
|
||||||
|
null,
|
||||||
|
false,
|
||||||
|
null,
|
||||||
|
failureReason,
|
||||||
|
plan?.PackageRoot,
|
||||||
|
plan?.WorkingDirectory,
|
||||||
|
plan is null ? null : HostLaunchPlanBuilder.FormatArgumentsForLog(plan.Arguments));
|
||||||
|
}
|
||||||
|
|
||||||
|
internal sealed record HostLaunchOutcome(
|
||||||
|
LauncherResult Result,
|
||||||
|
Process? Process,
|
||||||
|
LauncherResult? ImmediateResult,
|
||||||
|
Dictionary<string, string> Details)
|
||||||
|
{
|
||||||
|
public static HostLaunchOutcome FromResult(LauncherResult result) =>
|
||||||
|
new(result, null, result.Success ? result : null, result.Details);
|
||||||
|
|
||||||
|
public static HostLaunchOutcome FromImmediateResult(LauncherResult result) =>
|
||||||
|
new(result, null, result, result.Details);
|
||||||
|
|
||||||
|
public static HostLaunchOutcome FromProcess(Process process, LauncherResult result, Dictionary<string, string> details) =>
|
||||||
|
new(result, process, null, details);
|
||||||
|
}
|
||||||
320
LanMountainDesktop.Launcher/Startup/HostLaunchService.cs
Normal file
320
LanMountainDesktop.Launcher/Startup/HostLaunchService.cs
Normal file
@@ -0,0 +1,320 @@
|
|||||||
|
using System.Diagnostics;
|
||||||
|
using LanMountainDesktop.Launcher.Models;
|
||||||
|
using LanMountainDesktop.Launcher.Shell;
|
||||||
|
using LanMountainDesktop.Launcher.Views;
|
||||||
|
using LanMountainDesktop.Shared.Contracts.Launcher;
|
||||||
|
|
||||||
|
namespace LanMountainDesktop.Launcher.Startup;
|
||||||
|
|
||||||
|
internal sealed class HostLaunchService
|
||||||
|
{
|
||||||
|
public async Task<HostLaunchOutcome> LaunchAsync(LaunchContext context, bool forceDirectMode = false, string? retryTag = null)
|
||||||
|
{
|
||||||
|
var commandContext = context.CommandContext;
|
||||||
|
var deploymentLocator = context.DeploymentLocator;
|
||||||
|
var resolution = deploymentLocator.ResolveHostExecutable(commandContext);
|
||||||
|
if (!resolution.Success || string.IsNullOrWhiteSpace(resolution.ResolvedHostPath))
|
||||||
|
{
|
||||||
|
var (errorResult, selectedPath) = await LaunchUiPresenter.ShowHostNotFoundErrorAsync().ConfigureAwait(false);
|
||||||
|
if (errorResult == ErrorWindowResult.Retry)
|
||||||
|
{
|
||||||
|
if (!string.IsNullOrWhiteSpace(selectedPath) && File.Exists(selectedPath))
|
||||||
|
{
|
||||||
|
return await LaunchWithExplicitPathAsync(context, selectedPath, forceDirectMode, retryTag).ConfigureAwait(false);
|
||||||
|
}
|
||||||
|
|
||||||
|
return await LaunchAsync(context, forceDirectMode, retryTag).ConfigureAwait(false);
|
||||||
|
}
|
||||||
|
|
||||||
|
return HostLaunchOutcome.FromResult(LaunchResultBuilder.Build(
|
||||||
|
success: false,
|
||||||
|
stage: "launchHost",
|
||||||
|
code: "host_not_found",
|
||||||
|
message: "LanMountainDesktop host executable was not found.",
|
||||||
|
details: BuildResolutionDetails(resolution, null, null, "resolve")));
|
||||||
|
}
|
||||||
|
|
||||||
|
return await LaunchWithResolvedPathAsync(context, resolution, forceDirectMode, retryTag).ConfigureAwait(false);
|
||||||
|
}
|
||||||
|
|
||||||
|
public static LauncherResult? ValidateDotNetRuntimePrerequisite(
|
||||||
|
HostLaunchPlan plan,
|
||||||
|
HostResolutionResult resolution,
|
||||||
|
DotNetRuntimeProbeOptions? probeOptions = null)
|
||||||
|
{
|
||||||
|
ArgumentNullException.ThrowIfNull(plan);
|
||||||
|
ArgumentNullException.ThrowIfNull(resolution);
|
||||||
|
|
||||||
|
if (!DotNetRuntimeProbe.IsFrameworkDependentWindowsApp(plan.HostPath))
|
||||||
|
{
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
var runtime = DotNetRuntimeProbe.Probe(probeOptions);
|
||||||
|
Logger.Info(
|
||||||
|
$"Runtime prerequisite check completed. Available={runtime.IsAvailable}; " +
|
||||||
|
$"Architecture={runtime.Architecture}; Message='{runtime.Message}'.");
|
||||||
|
|
||||||
|
if (runtime.IsAvailable)
|
||||||
|
{
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
var details = BuildResolutionDetails(resolution, null, null, "runtime");
|
||||||
|
foreach (var pair in runtime.ToDetails())
|
||||||
|
{
|
||||||
|
details[pair.Key] = pair.Value;
|
||||||
|
}
|
||||||
|
|
||||||
|
return LaunchResultBuilder.Build(
|
||||||
|
success: false,
|
||||||
|
stage: "launchHost",
|
||||||
|
code: "dotnet_runtime_missing",
|
||||||
|
message: ".NET 10 Desktop Runtime is required before LanMountainDesktop can start.",
|
||||||
|
details: details,
|
||||||
|
errorMessage: runtime.Message);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static Task<HostLaunchOutcome> LaunchWithExplicitPathAsync(
|
||||||
|
LaunchContext context,
|
||||||
|
string hostPath,
|
||||||
|
bool forceDirectMode,
|
||||||
|
string? retryTag)
|
||||||
|
{
|
||||||
|
var resolution = new HostResolutionResult
|
||||||
|
{
|
||||||
|
Success = true,
|
||||||
|
ResolvedHostPath = Path.GetFullPath(hostPath),
|
||||||
|
ResolutionSource = "user_selected_path",
|
||||||
|
AppRoot = context.DeploymentLocator.GetAppRoot(),
|
||||||
|
ExplicitAppRoot = Path.GetDirectoryName(hostPath),
|
||||||
|
SearchedPaths = [Path.GetFullPath(hostPath)]
|
||||||
|
};
|
||||||
|
|
||||||
|
return LaunchWithResolvedPathAsync(context, resolution, forceDirectMode, retryTag);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static async Task<HostLaunchOutcome> LaunchWithResolvedPathAsync(
|
||||||
|
LaunchContext context,
|
||||||
|
HostResolutionResult resolution,
|
||||||
|
bool forceDirectMode,
|
||||||
|
string? retryTag)
|
||||||
|
{
|
||||||
|
var dataRoot = context.DataLocationResolver.ResolveDataRoot();
|
||||||
|
var plan = HostLaunchPlanBuilder.Build(context.CommandContext, context.DeploymentLocator, resolution, dataRoot);
|
||||||
|
var prerequisiteFailure = ValidateDotNetRuntimePrerequisite(plan, resolution);
|
||||||
|
if (prerequisiteFailure is not null)
|
||||||
|
{
|
||||||
|
return HostLaunchOutcome.FromResult(prerequisiteFailure);
|
||||||
|
}
|
||||||
|
|
||||||
|
var hostPath = plan.HostPath;
|
||||||
|
if (OperatingSystem.IsLinux() || OperatingSystem.IsMacOS())
|
||||||
|
{
|
||||||
|
EnsureExecutable(hostPath);
|
||||||
|
}
|
||||||
|
|
||||||
|
var primaryMode = HostStartMode.Direct;
|
||||||
|
var fallbackMode = !forceDirectMode && OperatingSystem.IsWindows()
|
||||||
|
? HostStartMode.ShellExecute
|
||||||
|
: (HostStartMode?)null;
|
||||||
|
|
||||||
|
var firstAttempt = await StartHostProcessAsync(plan, primaryMode, retryTag).ConfigureAwait(false);
|
||||||
|
if (firstAttempt.ProcessCreated && firstAttempt.Process is not null)
|
||||||
|
{
|
||||||
|
var firstDetails = BuildResolutionDetails(resolution, firstAttempt, null, null);
|
||||||
|
return HostLaunchOutcome.FromProcess(
|
||||||
|
firstAttempt.Process,
|
||||||
|
LaunchResultBuilder.Build(true, "launchHost", "ok", "Host launched.", firstDetails),
|
||||||
|
firstDetails);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (fallbackMode is null)
|
||||||
|
{
|
||||||
|
return BuildOutcomeFromAttempt(resolution, firstAttempt, null);
|
||||||
|
}
|
||||||
|
|
||||||
|
Logger.Warn(
|
||||||
|
$"Primary host start attempt failed. Retrying with fallback mode '{fallbackMode}'. " +
|
||||||
|
$"FailureReason='{firstAttempt.FailureReason ?? "unknown"}'; ExitCode='{firstAttempt.ExitCode?.ToString() ?? "<none>"}'.");
|
||||||
|
|
||||||
|
var secondAttempt = await StartHostProcessAsync(plan, fallbackMode.Value, retryTag).ConfigureAwait(false);
|
||||||
|
if (secondAttempt.ProcessCreated && secondAttempt.Process is not null)
|
||||||
|
{
|
||||||
|
var details = BuildResolutionDetails(resolution, firstAttempt, secondAttempt, null);
|
||||||
|
return HostLaunchOutcome.FromProcess(
|
||||||
|
secondAttempt.Process,
|
||||||
|
LaunchResultBuilder.Build(true, "launchHost", "ok", "Host launched.", details),
|
||||||
|
details);
|
||||||
|
}
|
||||||
|
|
||||||
|
return BuildOutcomeFromAttempt(resolution, secondAttempt, firstAttempt);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static HostLaunchOutcome BuildOutcomeFromAttempt(
|
||||||
|
HostResolutionResult resolution,
|
||||||
|
HostStartAttempt finalAttempt,
|
||||||
|
HostStartAttempt? previousAttempt)
|
||||||
|
{
|
||||||
|
var details = BuildResolutionDetails(
|
||||||
|
resolution,
|
||||||
|
previousAttempt ?? finalAttempt,
|
||||||
|
previousAttempt is null ? null : finalAttempt,
|
||||||
|
!finalAttempt.ProcessCreated
|
||||||
|
? "start"
|
||||||
|
: finalAttempt.ExitCode is int finalExitCode && HostActivationPolicy.IsFailedActivationExitCode(finalExitCode)
|
||||||
|
? "activation"
|
||||||
|
: "early-exit");
|
||||||
|
|
||||||
|
if (!finalAttempt.ProcessCreated)
|
||||||
|
{
|
||||||
|
return HostLaunchOutcome.FromResult(LaunchResultBuilder.Build(
|
||||||
|
false,
|
||||||
|
"launchHost",
|
||||||
|
"host_start_failed",
|
||||||
|
$"Failed to start host using start mode '{finalAttempt.StartMode}'.",
|
||||||
|
details));
|
||||||
|
}
|
||||||
|
|
||||||
|
if (finalAttempt.ExitCode is not null && HostActivationPolicy.IsSuccessfulActivationExitCode(finalAttempt.ExitCode.Value))
|
||||||
|
{
|
||||||
|
return HostLaunchOutcome.FromImmediateResult(LaunchResultBuilder.Build(
|
||||||
|
true,
|
||||||
|
"launch",
|
||||||
|
"activation_redirected",
|
||||||
|
"Launcher activation was redirected to the existing desktop instance.",
|
||||||
|
details));
|
||||||
|
}
|
||||||
|
|
||||||
|
if (finalAttempt.ExitCode is not null && HostActivationPolicy.IsFailedActivationExitCode(finalAttempt.ExitCode.Value))
|
||||||
|
{
|
||||||
|
return HostLaunchOutcome.FromResult(LaunchResultBuilder.Build(
|
||||||
|
false,
|
||||||
|
"launch",
|
||||||
|
"activation_failed",
|
||||||
|
$"Host activation handshake failed using start mode '{finalAttempt.StartMode}'.",
|
||||||
|
details));
|
||||||
|
}
|
||||||
|
|
||||||
|
return HostLaunchOutcome.FromResult(LaunchResultBuilder.Build(
|
||||||
|
false,
|
||||||
|
"launchHost",
|
||||||
|
"host_exited_early",
|
||||||
|
$"Host exited early using start mode '{finalAttempt.StartMode}'.",
|
||||||
|
details));
|
||||||
|
}
|
||||||
|
|
||||||
|
private static async Task<HostStartAttempt> StartHostProcessAsync(
|
||||||
|
HostLaunchPlan plan,
|
||||||
|
HostStartMode startMode,
|
||||||
|
string? retryTag)
|
||||||
|
{
|
||||||
|
var startInfo = new ProcessStartInfo
|
||||||
|
{
|
||||||
|
FileName = plan.HostPath,
|
||||||
|
WorkingDirectory = plan.WorkingDirectory,
|
||||||
|
UseShellExecute = startMode == HostStartMode.ShellExecute
|
||||||
|
};
|
||||||
|
|
||||||
|
if (startMode == HostStartMode.Direct)
|
||||||
|
{
|
||||||
|
foreach (var argument in plan.Arguments)
|
||||||
|
{
|
||||||
|
startInfo.ArgumentList.Add(argument);
|
||||||
|
}
|
||||||
|
|
||||||
|
foreach (var pair in plan.EnvironmentVariables)
|
||||||
|
{
|
||||||
|
startInfo.EnvironmentVariables[pair.Key] = pair.Value;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
startInfo.Arguments = HostLaunchPlanBuilder.FormatArgumentsForLog(plan.Arguments);
|
||||||
|
}
|
||||||
|
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var process = Process.Start(startInfo);
|
||||||
|
Logger.Info(
|
||||||
|
$"Host launch requested. Mode='{startMode}'; RetryTag='{retryTag ?? "<none>"}'; Path='{plan.HostPath}'; " +
|
||||||
|
$"PackageRoot='{plan.PackageRoot}'; WorkingDir='{plan.WorkingDirectory}'; Pid={(process is null ? -1 : process.Id)}; " +
|
||||||
|
$"Args='{HostLaunchPlanBuilder.FormatArgumentsForLog(plan.Arguments)}'.");
|
||||||
|
|
||||||
|
if (process is null)
|
||||||
|
{
|
||||||
|
return HostStartAttempt.StartFailed(startMode, "process_start_returned_null", plan);
|
||||||
|
}
|
||||||
|
|
||||||
|
await Task.Yield();
|
||||||
|
return HostStartAttempt.Started(startMode, process, plan);
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
Logger.Error($"Host start failed. Mode='{startMode}'.", ex);
|
||||||
|
return HostStartAttempt.StartFailed(startMode, ex.GetType().Name, plan);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
internal static Dictionary<string, string> BuildResolutionDetails(
|
||||||
|
HostResolutionResult resolution,
|
||||||
|
HostStartAttempt? firstAttempt,
|
||||||
|
HostStartAttempt? secondAttempt,
|
||||||
|
string? failureStage)
|
||||||
|
{
|
||||||
|
var details = new Dictionary<string, string>(StringComparer.OrdinalIgnoreCase)
|
||||||
|
{
|
||||||
|
["resolvedAppRoot"] = resolution.AppRoot,
|
||||||
|
["explicitAppRoot"] = resolution.ExplicitAppRoot ?? string.Empty,
|
||||||
|
["resolvedHostPath"] = resolution.ResolvedHostPath ?? string.Empty,
|
||||||
|
["resolutionSource"] = resolution.ResolutionSource ?? string.Empty,
|
||||||
|
["devModeConfigIgnored"] = resolution.DevModeConfigIgnored.ToString(),
|
||||||
|
["searchedPaths"] = string.Join(" | ", resolution.SearchedPaths),
|
||||||
|
["failureStage"] = failureStage ?? string.Empty
|
||||||
|
};
|
||||||
|
|
||||||
|
if (firstAttempt is not null)
|
||||||
|
{
|
||||||
|
details["startMode"] = firstAttempt.StartMode.ToString();
|
||||||
|
details["processCreated"] = firstAttempt.ProcessCreated.ToString();
|
||||||
|
details["hostPid"] = firstAttempt.ProcessId?.ToString() ?? string.Empty;
|
||||||
|
details["packageRoot"] = firstAttempt.PackageRoot ?? string.Empty;
|
||||||
|
details["workingDirectory"] = firstAttempt.WorkingDirectory ?? string.Empty;
|
||||||
|
details["arguments"] = firstAttempt.Arguments ?? string.Empty;
|
||||||
|
details["firstAttemptFailureReason"] = firstAttempt.FailureReason ?? string.Empty;
|
||||||
|
details["firstAttemptExitCode"] = firstAttempt.ExitCode?.ToString() ?? string.Empty;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (secondAttempt is not null)
|
||||||
|
{
|
||||||
|
details["fallbackStartMode"] = secondAttempt.StartMode.ToString();
|
||||||
|
details["fallbackProcessCreated"] = secondAttempt.ProcessCreated.ToString();
|
||||||
|
details["fallbackHostPid"] = secondAttempt.ProcessId?.ToString() ?? string.Empty;
|
||||||
|
details["fallbackPackageRoot"] = secondAttempt.PackageRoot ?? string.Empty;
|
||||||
|
details["fallbackWorkingDirectory"] = secondAttempt.WorkingDirectory ?? string.Empty;
|
||||||
|
details["fallbackArguments"] = secondAttempt.Arguments ?? string.Empty;
|
||||||
|
details["fallbackFailureReason"] = secondAttempt.FailureReason ?? string.Empty;
|
||||||
|
details["fallbackExitCode"] = secondAttempt.ExitCode?.ToString() ?? string.Empty;
|
||||||
|
}
|
||||||
|
|
||||||
|
return details;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static void EnsureExecutable(string path)
|
||||||
|
{
|
||||||
|
if (OperatingSystem.IsWindows())
|
||||||
|
{
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var mode = File.GetUnixFileMode(path);
|
||||||
|
mode |= UnixFileMode.UserExecute | UnixFileMode.GroupExecute | UnixFileMode.OtherExecute;
|
||||||
|
File.SetUnixFileMode(path, mode);
|
||||||
|
}
|
||||||
|
catch
|
||||||
|
{
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
510
LanMountainDesktop.Launcher/Startup/HostStartupMonitor.cs
Normal file
510
LanMountainDesktop.Launcher/Startup/HostStartupMonitor.cs
Normal file
@@ -0,0 +1,510 @@
|
|||||||
|
using System.Diagnostics;
|
||||||
|
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.Startup;
|
||||||
|
|
||||||
|
internal sealed class HostStartupMonitor
|
||||||
|
{
|
||||||
|
private static readonly string SoftTimeoutStatusMessage = Strings.Coordinator_SlowDeviceMessage;
|
||||||
|
private static readonly string SoftTimeoutDetailsMessage = Strings.Coordinator_RunningHostMessage;
|
||||||
|
|
||||||
|
internal sealed record Request(
|
||||||
|
Process HostProcess,
|
||||||
|
LanMountainDesktopIpcClient IpcClient,
|
||||||
|
StartupSuccessTracker SuccessTracker,
|
||||||
|
StartupAttemptRegistry AttemptRegistry,
|
||||||
|
StartupAttemptRecord? TrackedAttempt,
|
||||||
|
bool AttachedToExistingAttempt,
|
||||||
|
Dictionary<string, string> LaunchDetails,
|
||||||
|
TaskCompletionSource<StartupSuccessState> SuccessTcs,
|
||||||
|
TaskCompletionSource<string> ActivationFailedTcs,
|
||||||
|
ISplashStageReporter Reporter,
|
||||||
|
LoadingDetailsWindow? LoadingDetailsWindow,
|
||||||
|
LoadingStateMessage LoadingState,
|
||||||
|
StartupStage LastStage,
|
||||||
|
string LastStageMessage,
|
||||||
|
bool IpcConnected,
|
||||||
|
string ActivationFailureReason,
|
||||||
|
bool SoftTimeoutShown,
|
||||||
|
Action<bool?, bool, bool> PublishCoordinatorStatus,
|
||||||
|
Func<bool, bool, Dictionary<string, string>> ComposeLaunchDetails);
|
||||||
|
|
||||||
|
internal sealed record Outcome(
|
||||||
|
bool Success,
|
||||||
|
string Code,
|
||||||
|
string Message,
|
||||||
|
bool RecoveryActivationAttempted,
|
||||||
|
Dictionary<string, string> Details);
|
||||||
|
|
||||||
|
public async Task<Outcome> MonitorUntilCompleteAsync(Request request)
|
||||||
|
{
|
||||||
|
var ipcConnected = request.IpcConnected;
|
||||||
|
var softTimeoutShown = request.SoftTimeoutShown;
|
||||||
|
var lastStage = request.LastStage;
|
||||||
|
var lastStageMessage = request.LastStageMessage;
|
||||||
|
var activationFailureReason = request.ActivationFailureReason;
|
||||||
|
var loadingState = request.LoadingState;
|
||||||
|
PublicShellStatus? shellStatus = null;
|
||||||
|
var trackedAttempt = request.TrackedAttempt;
|
||||||
|
|
||||||
|
async Task<StartupSuccessState?> RefreshShellStatusAsync(string waitingMessage)
|
||||||
|
{
|
||||||
|
if (!request.IpcClient.IsConnected)
|
||||||
|
{
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
ipcConnected = true;
|
||||||
|
request.AttemptRegistry.MarkOwnedIpcConnected();
|
||||||
|
shellStatus = await TryGetPublicShellStatusAsync(request.IpcClient).ConfigureAwait(false);
|
||||||
|
StartupDiagnostics.TraceShellStatus("refresh", shellStatus, lastStage);
|
||||||
|
if (request.SuccessTracker.TryResolve(shellStatus, out var successState))
|
||||||
|
{
|
||||||
|
return successState;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (shellStatus is not null && !shellStatus.MainWindowOpened && !shellStatus.DesktopVisible)
|
||||||
|
{
|
||||||
|
request.AttemptRegistry.MarkOwnedWaitingForShell(waitingMessage);
|
||||||
|
}
|
||||||
|
|
||||||
|
request.PublishCoordinatorStatus(true, false, false);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
var connected = await PublicIpcConnection.TryConnectWithBackoffAsync(
|
||||||
|
request.IpcClient,
|
||||||
|
[
|
||||||
|
StartupTimeoutPolicy.InitialIpcConnectTimeout,
|
||||||
|
TimeSpan.FromMilliseconds(3000),
|
||||||
|
TimeSpan.FromMilliseconds(5000)
|
||||||
|
]).ConfigureAwait(false);
|
||||||
|
if (!connected)
|
||||||
|
{
|
||||||
|
Logger.Info("Host public IPC is not ready yet. Launcher will keep monitoring the host process and retry.");
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
var shellSuccess = await RefreshShellStatusAsync("Host public IPC is ready; waiting for desktop shell.")
|
||||||
|
.ConfigureAwait(false);
|
||||||
|
if (shellSuccess is not null)
|
||||||
|
{
|
||||||
|
request.SuccessTcs.TrySetResult(shellSuccess);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
var processExitTask = request.HostProcess.WaitForExitAsync();
|
||||||
|
var startedAt = trackedAttempt?.StartedAtUtc ?? DateTimeOffset.UtcNow;
|
||||||
|
var softTimeoutAt = startedAt + StartupTimeoutPolicy.SoftTimeout;
|
||||||
|
var hardTimeoutAt = startedAt + StartupTimeoutPolicy.HardTimeout;
|
||||||
|
var nextReconnectAttemptAt = DateTimeOffset.UtcNow + StartupTimeoutPolicy.IpcReconnectInterval;
|
||||||
|
var nextShellStatusPollAt = DateTimeOffset.UtcNow + StartupTimeoutPolicy.ShellStatusPollInterval;
|
||||||
|
var ipcReconnectAttemptIndex = 0;
|
||||||
|
var activationRetryAttempted = false;
|
||||||
|
|
||||||
|
while (true)
|
||||||
|
{
|
||||||
|
if (request.SuccessTcs.Task.IsCompleted)
|
||||||
|
{
|
||||||
|
var successState = await request.SuccessTcs.Task.ConfigureAwait(false);
|
||||||
|
request.AttemptRegistry.MarkOwnedSucceeded(successState.Stage, successState.Message);
|
||||||
|
request.PublishCoordinatorStatus(!request.HostProcess.HasExited, true, true);
|
||||||
|
return new Outcome(
|
||||||
|
true,
|
||||||
|
successState.Code,
|
||||||
|
successState.Message,
|
||||||
|
false,
|
||||||
|
request.ComposeLaunchDetails(!request.HostProcess.HasExited, false));
|
||||||
|
}
|
||||||
|
|
||||||
|
if (request.ActivationFailedTcs.Task.IsCompleted && !activationRetryAttempted)
|
||||||
|
{
|
||||||
|
activationRetryAttempted = true;
|
||||||
|
activationFailureReason = await request.ActivationFailedTcs.Task.ConfigureAwait(false);
|
||||||
|
Logger.Warn($"Activation failure received before startup success. Reason='{activationFailureReason}'.");
|
||||||
|
var activationRecovery = await TryRecoverActivationThroughExistingHostAsync(
|
||||||
|
request.IpcClient,
|
||||||
|
request.SuccessTracker,
|
||||||
|
TimeSpan.FromSeconds(1)).ConfigureAwait(false);
|
||||||
|
if (activationRecovery is not null)
|
||||||
|
{
|
||||||
|
request.AttemptRegistry.MarkOwnedSucceeded(activationRecovery.Stage, activationRecovery.Message);
|
||||||
|
request.PublishCoordinatorStatus(!request.HostProcess.HasExited, true, true);
|
||||||
|
return new Outcome(
|
||||||
|
true,
|
||||||
|
activationRecovery.Code,
|
||||||
|
activationRecovery.Message,
|
||||||
|
true,
|
||||||
|
request.ComposeLaunchDetails(!request.HostProcess.HasExited, true));
|
||||||
|
}
|
||||||
|
|
||||||
|
Logger.Info("Activation failure did not recover through public IPC yet. Launcher will keep monitoring the current host attempt.");
|
||||||
|
}
|
||||||
|
|
||||||
|
if (processExitTask.IsCompleted)
|
||||||
|
{
|
||||||
|
var exitCode = request.HostProcess.ExitCode;
|
||||||
|
Logger.Warn($"Host exited before startup success criteria were met. ExitCode={exitCode}.");
|
||||||
|
|
||||||
|
if (HostActivationPolicy.IsSuccessfulActivationExitCode(exitCode))
|
||||||
|
{
|
||||||
|
request.AttemptRegistry.MarkOwnedSucceeded(StartupStage.ActivationRedirected, "Host redirected activation to the existing desktop instance.");
|
||||||
|
request.PublishCoordinatorStatus(false, true, true);
|
||||||
|
return new Outcome(
|
||||||
|
true,
|
||||||
|
"activation_redirected",
|
||||||
|
"Host redirected activation to the existing desktop instance.",
|
||||||
|
false,
|
||||||
|
MergeExitCodeDetails(request.ComposeLaunchDetails(false, false), exitCode));
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!activationRetryAttempted && HostActivationPolicy.IsFailedActivationExitCode(exitCode))
|
||||||
|
{
|
||||||
|
activationRetryAttempted = true;
|
||||||
|
var activationRecovery = await TryRecoverActivationThroughExistingHostAsync(
|
||||||
|
request.IpcClient,
|
||||||
|
request.SuccessTracker,
|
||||||
|
TimeSpan.FromSeconds(2)).ConfigureAwait(false);
|
||||||
|
if (activationRecovery is not null)
|
||||||
|
{
|
||||||
|
request.AttemptRegistry.MarkOwnedSucceeded(activationRecovery.Stage, activationRecovery.Message);
|
||||||
|
request.PublishCoordinatorStatus(true, true, true);
|
||||||
|
return new Outcome(
|
||||||
|
true,
|
||||||
|
activationRecovery.Code,
|
||||||
|
activationRecovery.Message,
|
||||||
|
true,
|
||||||
|
MergeExitCodeDetails(request.ComposeLaunchDetails(true, true), exitCode));
|
||||||
|
}
|
||||||
|
|
||||||
|
Logger.Info("Activation exit code did not recover through public IPC. Launcher will report the activation failure without launching another host.");
|
||||||
|
}
|
||||||
|
|
||||||
|
request.AttemptRegistry.MarkOwnedFailed(lastStage, activationFailureReason);
|
||||||
|
request.PublishCoordinatorStatus(false, true, false);
|
||||||
|
return new Outcome(
|
||||||
|
false,
|
||||||
|
HostActivationPolicy.IsFailedActivationExitCode(exitCode) ? "activation_failed" : "host_exited_early",
|
||||||
|
HostActivationPolicy.IsFailedActivationExitCode(exitCode)
|
||||||
|
? $"Host activation handshake failed before the required startup state was reported. ExitCode={exitCode}."
|
||||||
|
: $"Host exited before the required startup state was reported. ExitCode={exitCode}.",
|
||||||
|
false,
|
||||||
|
MergeExitCodeDetails(request.ComposeLaunchDetails(false, false), exitCode));
|
||||||
|
}
|
||||||
|
|
||||||
|
var now = DateTimeOffset.UtcNow;
|
||||||
|
if (ipcConnected &&
|
||||||
|
!request.HostProcess.HasExited &&
|
||||||
|
now >= nextShellStatusPollAt)
|
||||||
|
{
|
||||||
|
var shellSuccess = await RefreshShellStatusAsync("Host public IPC is ready; waiting for desktop shell.")
|
||||||
|
.ConfigureAwait(false);
|
||||||
|
if (shellSuccess is not null)
|
||||||
|
{
|
||||||
|
request.SuccessTcs.TrySetResult(shellSuccess);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
nextShellStatusPollAt = DateTimeOffset.UtcNow + StartupTimeoutPolicy.ShellStatusPollInterval;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!ipcConnected &&
|
||||||
|
!request.HostProcess.HasExited &&
|
||||||
|
now >= nextReconnectAttemptAt)
|
||||||
|
{
|
||||||
|
var reconnectTimeout = StartupTimeoutPolicy.IpcReconnectAttemptTimeouts[
|
||||||
|
Math.Min(ipcReconnectAttemptIndex, StartupTimeoutPolicy.IpcReconnectAttemptTimeouts.Length - 1)];
|
||||||
|
ipcReconnectAttemptIndex++;
|
||||||
|
connected = await PublicIpcConnection.TryConnectAsync(request.IpcClient, reconnectTimeout).ConfigureAwait(false);
|
||||||
|
if (connected)
|
||||||
|
{
|
||||||
|
ipcConnected = true;
|
||||||
|
var shellSuccess = await RefreshShellStatusAsync("Host public IPC reconnected; waiting for desktop shell.")
|
||||||
|
.ConfigureAwait(false);
|
||||||
|
if (shellSuccess is not null)
|
||||||
|
{
|
||||||
|
request.SuccessTcs.TrySetResult(shellSuccess);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
nextReconnectAttemptAt = DateTimeOffset.UtcNow + StartupTimeoutPolicy.IpcReconnectInterval;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!softTimeoutShown &&
|
||||||
|
now >= softTimeoutAt &&
|
||||||
|
(!request.HostProcess.HasExited || ipcConnected))
|
||||||
|
{
|
||||||
|
softTimeoutShown = true;
|
||||||
|
request.AttemptRegistry.MarkOwnedSoftTimeout(SoftTimeoutStatusMessage);
|
||||||
|
request.Reporter.Report("delayed", SoftTimeoutStatusMessage);
|
||||||
|
loadingState = BuildDelayedLoadingState(
|
||||||
|
loadingState,
|
||||||
|
SoftTimeoutStatusMessage,
|
||||||
|
SoftTimeoutDetailsMessage,
|
||||||
|
trackedAttempt?.StartedAtUtc ?? startedAt);
|
||||||
|
request.LoadingDetailsWindow?.UpdateLoadingState(loadingState);
|
||||||
|
request.PublishCoordinatorStatus(!request.HostProcess.HasExited, false, false);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (now >= hardTimeoutAt)
|
||||||
|
{
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
var nextCheckpointAt = hardTimeoutAt;
|
||||||
|
if (!softTimeoutShown && softTimeoutAt < nextCheckpointAt)
|
||||||
|
{
|
||||||
|
nextCheckpointAt = softTimeoutAt;
|
||||||
|
}
|
||||||
|
|
||||||
|
var delay = nextCheckpointAt - now;
|
||||||
|
if (delay > TimeSpan.FromSeconds(1))
|
||||||
|
{
|
||||||
|
delay = TimeSpan.FromSeconds(1);
|
||||||
|
}
|
||||||
|
else if (delay < TimeSpan.FromMilliseconds(100))
|
||||||
|
{
|
||||||
|
delay = TimeSpan.FromMilliseconds(100);
|
||||||
|
}
|
||||||
|
|
||||||
|
await Task.WhenAny(
|
||||||
|
request.SuccessTcs.Task,
|
||||||
|
request.ActivationFailedTcs.Task,
|
||||||
|
processExitTask,
|
||||||
|
Task.Delay(delay)).ConfigureAwait(false);
|
||||||
|
}
|
||||||
|
|
||||||
|
var recoveryActivationAttempted = false;
|
||||||
|
if (!connected && !request.HostProcess.HasExited)
|
||||||
|
{
|
||||||
|
connected = await PublicIpcConnection.TryConnectAsync(request.IpcClient, TimeSpan.FromSeconds(3)).ConfigureAwait(false);
|
||||||
|
if (connected)
|
||||||
|
{
|
||||||
|
var shellSuccess = await RefreshShellStatusAsync("Host public IPC is ready; waiting for desktop shell.")
|
||||||
|
.ConfigureAwait(false);
|
||||||
|
if (shellSuccess is not null)
|
||||||
|
{
|
||||||
|
request.AttemptRegistry.MarkOwnedSucceeded(shellSuccess.Stage, shellSuccess.Message);
|
||||||
|
request.PublishCoordinatorStatus(true, true, true);
|
||||||
|
return new Outcome(
|
||||||
|
true,
|
||||||
|
shellSuccess.Code,
|
||||||
|
shellSuccess.Message,
|
||||||
|
false,
|
||||||
|
request.ComposeLaunchDetails(true, false));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (connected && !request.HostProcess.HasExited)
|
||||||
|
{
|
||||||
|
recoveryActivationAttempted = true;
|
||||||
|
var recoveryOutcome = await TryRecoverWithPublicActivationAsync(
|
||||||
|
request.IpcClient,
|
||||||
|
request.HostProcess,
|
||||||
|
request.SuccessTcs.Task,
|
||||||
|
request.SuccessTracker).ConfigureAwait(false);
|
||||||
|
if (recoveryOutcome is not null)
|
||||||
|
{
|
||||||
|
request.AttemptRegistry.MarkOwnedSucceeded(recoveryOutcome.Stage, recoveryOutcome.Message);
|
||||||
|
request.PublishCoordinatorStatus(!request.HostProcess.HasExited, true, true);
|
||||||
|
return new Outcome(
|
||||||
|
true,
|
||||||
|
recoveryOutcome.Code,
|
||||||
|
recoveryOutcome.Message,
|
||||||
|
true,
|
||||||
|
request.ComposeLaunchDetails(!request.HostProcess.HasExited, true));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (connected && !request.HostProcess.HasExited)
|
||||||
|
{
|
||||||
|
request.AttemptRegistry.MarkOwnedWaitingForShell("Host process is still running after the launcher wait window.");
|
||||||
|
shellStatus = await TryGetPublicShellStatusAsync(request.IpcClient).ConfigureAwait(false);
|
||||||
|
if (request.SuccessTracker.TryResolve(shellStatus, out var finalShellSuccess))
|
||||||
|
{
|
||||||
|
request.AttemptRegistry.MarkOwnedSucceeded(finalShellSuccess.Stage, finalShellSuccess.Message);
|
||||||
|
request.PublishCoordinatorStatus(true, true, true);
|
||||||
|
return new Outcome(
|
||||||
|
true,
|
||||||
|
finalShellSuccess.Code,
|
||||||
|
finalShellSuccess.Message,
|
||||||
|
recoveryActivationAttempted,
|
||||||
|
request.ComposeLaunchDetails(true, recoveryActivationAttempted));
|
||||||
|
}
|
||||||
|
|
||||||
|
request.PublishCoordinatorStatus(true, true, false);
|
||||||
|
return new Outcome(
|
||||||
|
false,
|
||||||
|
"shell_not_ready",
|
||||||
|
"Host public IPC is connected, but the desktop shell did not create or show the main window in time.",
|
||||||
|
recoveryActivationAttempted,
|
||||||
|
request.ComposeLaunchDetails(true, recoveryActivationAttempted));
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!connected && !request.HostProcess.HasExited)
|
||||||
|
{
|
||||||
|
request.AttemptRegistry.MarkOwnedWaitingForShell("Host process is still running, but public IPC is not ready yet.");
|
||||||
|
request.PublishCoordinatorStatus(true, false, true);
|
||||||
|
return new Outcome(
|
||||||
|
true,
|
||||||
|
"startup_pending",
|
||||||
|
"Host process is still running; Launcher will not start another process while public IPC finishes startup.",
|
||||||
|
recoveryActivationAttempted,
|
||||||
|
request.ComposeLaunchDetails(true, recoveryActivationAttempted));
|
||||||
|
}
|
||||||
|
|
||||||
|
request.AttemptRegistry.MarkOwnedFailed(lastStage, activationFailureReason);
|
||||||
|
request.PublishCoordinatorStatus(!request.HostProcess.HasExited, true, false);
|
||||||
|
return new Outcome(
|
||||||
|
false,
|
||||||
|
"desktop_not_visible",
|
||||||
|
$"Host process started, but it never reached the required startup state within {StartupTimeoutPolicy.HardTimeout.TotalSeconds:0} seconds.",
|
||||||
|
recoveryActivationAttempted,
|
||||||
|
request.ComposeLaunchDetails(!request.HostProcess.HasExited, recoveryActivationAttempted));
|
||||||
|
}
|
||||||
|
|
||||||
|
internal static async Task<StartupSuccessState?> TryRecoverActivationThroughExistingHostAsync(
|
||||||
|
LanMountainDesktopIpcClient ipcClient,
|
||||||
|
StartupSuccessTracker startupSuccessTracker,
|
||||||
|
TimeSpan timeout)
|
||||||
|
{
|
||||||
|
var activation = await TryActivateExistingHostWithStatusAsync(ipcClient, timeout).ConfigureAwait(false);
|
||||||
|
if (activation is null)
|
||||||
|
{
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (startupSuccessTracker.TryResolve(activation.Status, out var shellSuccess))
|
||||||
|
{
|
||||||
|
return shellSuccess;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (activation.Accepted)
|
||||||
|
{
|
||||||
|
return startupSuccessTracker.BuildRecoverySuccessState();
|
||||||
|
}
|
||||||
|
|
||||||
|
return HostActivationPolicy.IsRecoverableActivationFailure(activation)
|
||||||
|
? new StartupSuccessState(
|
||||||
|
StartupStage.Ready,
|
||||||
|
"startup_pending",
|
||||||
|
activation.Message)
|
||||||
|
: null;
|
||||||
|
}
|
||||||
|
|
||||||
|
internal static async Task<PublicShellStatus?> TryGetPublicShellStatusAsync(LanMountainDesktopIpcClient ipcClient)
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var shellProxy = ipcClient.CreateProxy<IPublicShellControlService>();
|
||||||
|
return await shellProxy.GetShellStatusAsync().ConfigureAwait(false);
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
Logger.Warn($"Failed to query public shell status: {ex.Message}");
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private static async Task<PublicShellActivationResult?> TryActivateExistingHostWithStatusAsync(
|
||||||
|
LanMountainDesktopIpcClient ipcClient,
|
||||||
|
TimeSpan timeout)
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var connected = ipcClient.IsConnected ||
|
||||||
|
await PublicIpcConnection.TryConnectAsync(ipcClient, timeout).ConfigureAwait(false);
|
||||||
|
if (!connected)
|
||||||
|
{
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
var shellProxy = ipcClient.CreateProxy<IPublicShellControlService>();
|
||||||
|
return await shellProxy.ActivateMainWindowWithStatusAsync().ConfigureAwait(false);
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
Logger.Info($"Existing host activation probe did not complete: {ex.Message}");
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private static async Task<StartupSuccessState?> TryRecoverWithPublicActivationAsync(
|
||||||
|
LanMountainDesktopIpcClient ipcClient,
|
||||||
|
Process hostProcess,
|
||||||
|
Task<StartupSuccessState> successTask,
|
||||||
|
StartupSuccessTracker startupSuccessTracker)
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var shellProxy = ipcClient.CreateProxy<IPublicShellControlService>();
|
||||||
|
var activation = await shellProxy.ActivateMainWindowWithStatusAsync().ConfigureAwait(false);
|
||||||
|
StartupDiagnostics.TraceShellStatus("recovery_activation", activation.Status);
|
||||||
|
if (startupSuccessTracker.TryResolve(activation.Status, out var shellSuccess))
|
||||||
|
{
|
||||||
|
return shellSuccess;
|
||||||
|
}
|
||||||
|
|
||||||
|
var completedTask = await Task.WhenAny(successTask, Task.Delay(TimeSpan.FromSeconds(5))).ConfigureAwait(false);
|
||||||
|
if (completedTask == successTask)
|
||||||
|
{
|
||||||
|
return await successTask.ConfigureAwait(false);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!hostProcess.HasExited && (activation.Accepted || HostActivationPolicy.IsRecoverableActivationFailure(activation)))
|
||||||
|
{
|
||||||
|
return startupSuccessTracker.BuildRecoverySuccessState();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
Logger.Warn($"Public activation recovery failed: {ex.Message}");
|
||||||
|
}
|
||||||
|
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
internal static LoadingStateMessage BuildDelayedLoadingState(
|
||||||
|
LoadingStateMessage loadingState,
|
||||||
|
string summaryMessage,
|
||||||
|
string detailMessage,
|
||||||
|
DateTimeOffset startedAtUtc)
|
||||||
|
{
|
||||||
|
var delayedItems = loadingState.ActiveItems
|
||||||
|
.Where(item => !string.Equals(item.Id, "launcher-soft-timeout", StringComparison.OrdinalIgnoreCase))
|
||||||
|
.ToList();
|
||||||
|
|
||||||
|
delayedItems.Insert(0, new LoadingItem
|
||||||
|
{
|
||||||
|
Id = "launcher-soft-timeout",
|
||||||
|
Type = LoadingItemType.System,
|
||||||
|
Name = "Startup still in progress",
|
||||||
|
Description = detailMessage,
|
||||||
|
State = LoadingState.Delayed,
|
||||||
|
ProgressPercent = Math.Max(loadingState.OverallProgressPercent, 1),
|
||||||
|
Message = detailMessage,
|
||||||
|
StartTime = startedAtUtc
|
||||||
|
});
|
||||||
|
|
||||||
|
return loadingState with
|
||||||
|
{
|
||||||
|
ActiveItems = delayedItems,
|
||||||
|
Message = summaryMessage,
|
||||||
|
Timestamp = DateTimeOffset.UtcNow,
|
||||||
|
TotalCount = Math.Max(loadingState.TotalCount, delayedItems.Count)
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
private static Dictionary<string, string> MergeExitCodeDetails(Dictionary<string, string> details, int exitCode)
|
||||||
|
{
|
||||||
|
details["exitCode"] = exitCode.ToString();
|
||||||
|
return details;
|
||||||
|
}
|
||||||
|
}
|
||||||
49
LanMountainDesktop.Launcher/Startup/LaunchAttemptDetails.cs
Normal file
49
LanMountainDesktop.Launcher/Startup/LaunchAttemptDetails.cs
Normal file
@@ -0,0 +1,49 @@
|
|||||||
|
using LanMountainDesktop.Launcher.Models;
|
||||||
|
using LanMountainDesktop.Shared.Contracts.Launcher;
|
||||||
|
|
||||||
|
namespace LanMountainDesktop.Launcher.Startup;
|
||||||
|
|
||||||
|
internal static class LaunchAttemptDetails
|
||||||
|
{
|
||||||
|
public static Dictionary<string, string> Build(
|
||||||
|
StartupAttemptRecord? trackedAttempt,
|
||||||
|
bool attachedToExistingAttempt,
|
||||||
|
bool ipcConnected,
|
||||||
|
bool hostProcessAlive,
|
||||||
|
StartupStage lastStage,
|
||||||
|
string lastStageMessage,
|
||||||
|
string? activationFailureReason,
|
||||||
|
bool softTimeoutShown,
|
||||||
|
bool recoveryActivationAttempted)
|
||||||
|
{
|
||||||
|
var details = new Dictionary<string, string>(StringComparer.OrdinalIgnoreCase)
|
||||||
|
{
|
||||||
|
["hostProcessAlive"] = hostProcessAlive.ToString(),
|
||||||
|
["attachedToExistingAttempt"] = attachedToExistingAttempt.ToString(),
|
||||||
|
["ipcConnected"] = ipcConnected.ToString(),
|
||||||
|
["ipcStage"] = lastStage.ToString(),
|
||||||
|
["ipcMessage"] = lastStageMessage,
|
||||||
|
["activationFailureReason"] = activationFailureReason ?? string.Empty,
|
||||||
|
["softTimeoutShown"] = softTimeoutShown.ToString(),
|
||||||
|
["recoveryActivationAttempted"] = recoveryActivationAttempted.ToString()
|
||||||
|
};
|
||||||
|
|
||||||
|
if (trackedAttempt is not null)
|
||||||
|
{
|
||||||
|
details["startupAttemptId"] = trackedAttempt.AttemptId;
|
||||||
|
details["startupAttemptState"] = trackedAttempt.State.ToString();
|
||||||
|
details["startupAttemptStartedAtUtc"] = trackedAttempt.StartedAtUtc.ToString("O");
|
||||||
|
details["startupAttemptUpdatedAtUtc"] = trackedAttempt.UpdatedAtUtc.ToString("O");
|
||||||
|
details["startupAttemptHeartbeatAtUtc"] = trackedAttempt.HeartbeatAtUtc.ToString("O");
|
||||||
|
details["successPolicy"] = trackedAttempt.SuccessPolicy;
|
||||||
|
details["hostPid"] = trackedAttempt.HostPid.ToString();
|
||||||
|
details["coordinatorPid"] = trackedAttempt.CoordinatorPid.ToString();
|
||||||
|
details["coordinatorPipeName"] = trackedAttempt.CoordinatorPipeName;
|
||||||
|
details["reservedBeforeHostStart"] = trackedAttempt.ReservedBeforeHostStart.ToString();
|
||||||
|
details["publicIpcConnected"] = trackedAttempt.PublicIpcConnected.ToString();
|
||||||
|
details["shellStatus"] = trackedAttempt.ShellStatus;
|
||||||
|
}
|
||||||
|
|
||||||
|
return details;
|
||||||
|
}
|
||||||
|
}
|
||||||
190
LanMountainDesktop.Launcher/Startup/LaunchPipeline.cs
Normal file
190
LanMountainDesktop.Launcher/Startup/LaunchPipeline.cs
Normal file
@@ -0,0 +1,190 @@
|
|||||||
|
using System.Diagnostics;
|
||||||
|
using LanMountainDesktop.Launcher.Models;
|
||||||
|
using LanMountainDesktop.Launcher.Views;
|
||||||
|
using LanMountainDesktop.Shared.Contracts.Launcher;
|
||||||
|
using LanMountainDesktop.Shared.IPC;
|
||||||
|
using LanMountainDesktop.Shared.IPC.Abstractions.Services;
|
||||||
|
|
||||||
|
namespace LanMountainDesktop.Launcher.Startup;
|
||||||
|
|
||||||
|
internal enum LaunchPhaseStatus
|
||||||
|
{
|
||||||
|
Continue,
|
||||||
|
Completed
|
||||||
|
}
|
||||||
|
|
||||||
|
internal sealed record LaunchPhaseResult(LaunchPhaseStatus Status, LauncherResult? Result = null);
|
||||||
|
|
||||||
|
internal interface ILaunchPhase
|
||||||
|
{
|
||||||
|
string Name { get; }
|
||||||
|
|
||||||
|
Task<LaunchPhaseResult> ExecuteAsync(LaunchContext context, CancellationToken cancellationToken = default);
|
||||||
|
}
|
||||||
|
|
||||||
|
internal sealed class LaunchContext
|
||||||
|
{
|
||||||
|
public required CommandContext CommandContext { get; init; }
|
||||||
|
public required DeploymentLocator DeploymentLocator { get; init; }
|
||||||
|
public required OobeStateService OobeStateService { get; init; }
|
||||||
|
public required StartupAttemptRegistry StartupAttemptRegistry { get; init; }
|
||||||
|
public LauncherCoordinatorIpcServer? CoordinatorIpcServer { get; init; }
|
||||||
|
public required DataLocationResolver DataLocationResolver { get; init; }
|
||||||
|
public required IReadOnlyList<IOobeStep> OobeSteps { get; init; }
|
||||||
|
|
||||||
|
public SplashWindow SplashWindow { get; set; } = null!;
|
||||||
|
public LoadingDetailsWindow? LoadingDetailsWindow { get; set; }
|
||||||
|
public ISplashStageReporter Reporter { get; set; } = null!;
|
||||||
|
public LanMountainDesktopIpcClient IpcClient { get; set; } = null!;
|
||||||
|
public StartupSuccessTracker SuccessTracker { get; set; } = null!;
|
||||||
|
public TaskCompletionSource<StartupSuccessState> SuccessTcs { get; set; } = null!;
|
||||||
|
public TaskCompletionSource<string> ActivationFailedTcs { get; set; } = null!;
|
||||||
|
public LoadingStateMessage LoadingState { get; set; }
|
||||||
|
public Dictionary<string, string> LauncherContextDetails { get; set; } = [];
|
||||||
|
public OobeLaunchDecision OobeDecision { get; set; } = null!;
|
||||||
|
|
||||||
|
public StartupStage LastStage { get; set; } = StartupStage.Initializing;
|
||||||
|
public string LastStageMessage { get; set; } = "launcher-started";
|
||||||
|
public string ActivationFailureReason { get; set; } = string.Empty;
|
||||||
|
public bool IpcConnected { get; set; }
|
||||||
|
public bool SoftTimeoutShown { get; set; }
|
||||||
|
public bool AttachedToExistingAttempt { get; set; }
|
||||||
|
public bool WindowsClosingByOrchestrator { get; set; }
|
||||||
|
public StartupAttemptRecord? TrackedAttempt { get; set; }
|
||||||
|
public PublicShellStatus? ShellStatus { get; set; }
|
||||||
|
public HostLaunchOutcome? LaunchOutcome { get; set; }
|
||||||
|
|
||||||
|
public Action<bool?, bool, bool> PublishCoordinatorStatus { get; set; } = static (_, _, _) => { };
|
||||||
|
public EventHandler? SplashClosedHandler { get; set; }
|
||||||
|
}
|
||||||
|
|
||||||
|
internal sealed class LaunchPipeline
|
||||||
|
{
|
||||||
|
private readonly IReadOnlyList<ILaunchPhase> _phases;
|
||||||
|
|
||||||
|
public LaunchPipeline(IEnumerable<ILaunchPhase> phases)
|
||||||
|
{
|
||||||
|
_phases = phases.ToList();
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task<LauncherResult> ExecuteAsync(LaunchContext context, CancellationToken cancellationToken = default)
|
||||||
|
{
|
||||||
|
foreach (var phase in _phases)
|
||||||
|
{
|
||||||
|
Logger.Info($"Launch pipeline entering phase '{phase.Name}'.");
|
||||||
|
var phaseResult = await phase.ExecuteAsync(context, cancellationToken).ConfigureAwait(false);
|
||||||
|
if (phaseResult.Status == LaunchPhaseStatus.Completed)
|
||||||
|
{
|
||||||
|
return phaseResult.Result ?? LaunchResultBuilder.BuildFailure(
|
||||||
|
"launch",
|
||||||
|
"phase_completed_without_result",
|
||||||
|
$"Launch phase '{phase.Name}' completed without a result.");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return LaunchResultBuilder.BuildFailure(
|
||||||
|
"launch",
|
||||||
|
"pipeline_incomplete",
|
||||||
|
"Launch pipeline finished without producing a result.");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
internal static class LaunchResultBuilder
|
||||||
|
{
|
||||||
|
public static LauncherResult Build(
|
||||||
|
bool success,
|
||||||
|
string stage,
|
||||||
|
string code,
|
||||||
|
string message,
|
||||||
|
Dictionary<string, string>? details = null,
|
||||||
|
string? errorMessage = null)
|
||||||
|
{
|
||||||
|
Logger.Info($"Launcher result prepared. Success={success}; Stage='{stage}'; Code='{code}'.");
|
||||||
|
return new LauncherResult
|
||||||
|
{
|
||||||
|
Success = success,
|
||||||
|
Stage = stage,
|
||||||
|
Code = code,
|
||||||
|
Message = message,
|
||||||
|
ErrorMessage = errorMessage,
|
||||||
|
Details = details ?? []
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
public static LauncherResult BuildFailure(string stage, string code, string message) =>
|
||||||
|
Build(false, stage, code, message);
|
||||||
|
|
||||||
|
public static LauncherResult WithAdditionalDetails(LauncherResult result, Dictionary<string, string> details) =>
|
||||||
|
new()
|
||||||
|
{
|
||||||
|
Success = result.Success,
|
||||||
|
Stage = result.Stage,
|
||||||
|
Code = result.Code,
|
||||||
|
Message = result.Message,
|
||||||
|
CurrentVersion = result.CurrentVersion,
|
||||||
|
TargetVersion = result.TargetVersion,
|
||||||
|
RolledBackTo = result.RolledBackTo,
|
||||||
|
Details = MergeDetails(details, result.Details),
|
||||||
|
InstalledPackagePath = result.InstalledPackagePath,
|
||||||
|
ManifestId = result.ManifestId,
|
||||||
|
ManifestName = result.ManifestName,
|
||||||
|
ErrorMessage = result.ErrorMessage
|
||||||
|
};
|
||||||
|
|
||||||
|
public static Dictionary<string, string> BuildLauncherContextDetails(
|
||||||
|
CommandContext context,
|
||||||
|
OobeLaunchDecision oobeDecision,
|
||||||
|
string appRoot) =>
|
||||||
|
new(StringComparer.OrdinalIgnoreCase)
|
||||||
|
{
|
||||||
|
["command"] = context.Command,
|
||||||
|
["launchSource"] = context.LaunchSource,
|
||||||
|
["isGuiMode"] = context.IsGuiCommand.ToString(),
|
||||||
|
["isDebugMode"] = context.IsDebugMode.ToString(),
|
||||||
|
["isElevated"] = oobeDecision.IsElevated.ToString(),
|
||||||
|
["resolvedAppRoot"] = appRoot,
|
||||||
|
["oobeStatePath"] = oobeDecision.StatePath,
|
||||||
|
["oobeStateStatus"] = oobeDecision.Status.ToString(),
|
||||||
|
["oobeDecision"] = oobeDecision.ShouldShowOobe ? "show" : "skip",
|
||||||
|
["oobeSuppressionReason"] = oobeDecision.SuppressionReason,
|
||||||
|
["oobeResultCode"] = oobeDecision.ResultCode,
|
||||||
|
["userSid"] = oobeDecision.UserSid ?? string.Empty,
|
||||||
|
["usedLegacyOobeMarker"] = oobeDecision.UsedLegacyMarker.ToString(),
|
||||||
|
["migratedLegacyOobeMarker"] = oobeDecision.MigratedLegacyMarker.ToString(),
|
||||||
|
["oobeStateError"] = oobeDecision.ErrorMessage
|
||||||
|
};
|
||||||
|
|
||||||
|
public static Dictionary<string, string> MergeDetails(
|
||||||
|
Dictionary<string, string> left,
|
||||||
|
Dictionary<string, string> right)
|
||||||
|
{
|
||||||
|
var merged = new Dictionary<string, string>(left, StringComparer.OrdinalIgnoreCase);
|
||||||
|
foreach (var pair in right)
|
||||||
|
{
|
||||||
|
merged[pair.Key] = pair.Value;
|
||||||
|
}
|
||||||
|
|
||||||
|
return merged;
|
||||||
|
}
|
||||||
|
|
||||||
|
public static bool TryGetLiveProcess(int processId, out Process? process)
|
||||||
|
{
|
||||||
|
process = null;
|
||||||
|
if (processId <= 0)
|
||||||
|
{
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
try
|
||||||
|
{
|
||||||
|
process = Process.GetProcessById(processId);
|
||||||
|
return !process.HasExited;
|
||||||
|
}
|
||||||
|
catch
|
||||||
|
{
|
||||||
|
process?.Dispose();
|
||||||
|
process = null;
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user