Compare commits

...

43 Commits

Author SHA1 Message Date
lincube
8c88e305ee fix.在线安装器,启动器 2026-06-05 11:08:11 +08:00
lincube
bb4e90ea8d fix.依旧在调整我们的在线安装器 2026-06-03 12:32:56 +08:00
lincube
75c7aece4f fix.在线安装器 2026-06-03 07:30:54 +08:00
lincube
e888b0423a Create installer-build.yml 2026-06-03 01:19:48 +08:00
lincube
28b06031f7 feat.在线安装器,更好的Issue与pull request模板。 2026-06-03 00:50:52 +08:00
lincube
29bd47986c Merge branch 'main' of https://github.com/wwiinnddyy/LanMountainDesktop 2026-06-02 16:31:36 +08:00
lincube
b12c9bf11d fix.元素动画系统导致的调整组件闪现问题 2026-06-02 16:31:29 +08:00
lincube
dd73e02bce 更新 plonds-uploader.yml 2026-06-02 16:24:58 +08:00
lincube
ed66869c8d 更新 plonds-uploader.yml 2026-06-02 15:55:37 +08:00
lincube
8403b89a15 fiz.4×2日历组件日期显示修复 2026-06-02 14:28:33 +08:00
lincube
0ea98c08bf feat.PLONDS客户端补全 2026-06-02 13:16:13 +08:00
lincube
54d97e312d fix.plonds-s3-multipart-upload 2026-06-02 10:09:06 +08:00
lincube
04b95020bd fix.plonds-s3-resumable-publish 2026-06-02 09:27:08 +08:00
lincube
cf08269e15 fix.plonds-s3-upload-timeout 2026-06-02 08:51:53 +08:00
lincube
03e4442e74 feat.PLONDS客户端 2026-06-01 19:48:51 +08:00
lincube
0c8830133a feat.Publisher完整包上传 2026-06-01 17:28:26 +08:00
lincube
131043fe37 changed.修改了PLONDS上传逻辑 2026-06-01 16:53:23 +08:00
lincube
a2ac302ee7 fix. 插件安装修复 2026-06-01 01:12:52 +08:00
lincube
c351a8e7f3 feat.airapp剥离启动器 2026-05-31 19:41:10 +08:00
lincube
21e970c5b6 fix.修复了窗口问题,以及多次显示圆角调节选项的问题。 2026-05-31 12:12:56 +08:00
lincube
17873f0f43 fix.修复设置页面 2026-05-30 17:15:16 +08:00
lincube
4051b5cd74 qchanged. 修改了Mac OS打包逻辑 2026-05-30 16:11:25 +08:00
lincube
5be4537b2c feat.Arknight endfiled 2026-05-30 13:50:13 +08:00
lincube
c5e75244af feat.PLONDS系统会不断地改进 2026-05-30 13:47:15 +08:00
lincube
6a650873bc feat..去除了冗余的字体文件,又修改了PLONDS系统 2026-05-30 11:56:50 +08:00
lincube
d004088601 chabged.进一步清理启动器内的更新逻辑 2026-05-29 22:16:40 +08:00
lincube
a1cc0ee2bf changed.PLONDS启动 2026-05-28 20:07:03 +08:00
lincube
313d093257 changed.对启动器重构的尝试 2026-05-28 15:14:37 +08:00
lincube
1ef47c780b refactor(launcher): add DI, IUpdateEngine facade, and architecture tests
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-05-28 11:13:14 +08:00
lincube
a26b6faace refactor(launcher): replace LauncherFlowCoordinator with LaunchPipeline and slim App shell
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-05-28 11:03:49 +08:00
lincube
b219f109ec refactor(launcher): reorganize into responsibility folders
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-05-28 10:43:30 +08:00
lincube
1ee6e68f33 refactor(launcher): converge plugin pending to Host via PluginPackaging
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-05-28 10:28:31 +08:00
lincube
545dee85a7 fix(launcher): wire HostStartupMonitor into launch flow
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-05-28 10:28:16 +08:00
lincube
ebe35d6f91 fix(launcher): extract startup subsystem and harden IPC detection
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-05-28 10:27:33 +08:00
lincube
63f08987a7 feat.升级了相关的依赖 2026-05-27 11:52:24 +08:00
lincube
ce41fd676c changed.调整了遥测系统。 2026-05-27 09:41:18 +08:00
lincube
c1f148f7d6 changed.更新界面微调 2026-05-26 17:52:44 +08:00
lincube
a75ed0ced1 fix.使用正确的图标 2026-05-26 17:40:35 +08:00
lincube
2dc40c53e2 feat.更新界面中文补充 2026-05-26 17:21:21 +08:00
lincube
a99ed9fef2 changed.将更新系统全面纳入到统一的设置接口 2026-05-26 14:25:52 +08:00
lincube
553cee54f9 fis.airapp相关运行时修复以及打包构建工作流修复 2026-05-26 13:25:42 +08:00
lincube
1d7a878d55 changed.调整了对话框,比如多开提醒,删除页面二次确认等 2026-05-26 12:48:36 +08:00
lincube
0361b83ea2 feat.添加了提交文档,同时修改了圆角规范 2026-05-25 19:38:32 +08:00
459 changed files with 30924 additions and 14584 deletions

View File

@@ -0,0 +1,432 @@
---
name: Launcher 单项目解耦
overview: 在保持单一 LanMountainDesktop.Launcher 项目、单一 exe、零部署风险的前提下按职责域增量重构目录分层、RunAsync→Pipeline+Phase、UpdateEngine→策略类、App→纯 Avalonia+LauncherOrchestrator执行过程中由 Agent 自主 Git 提交,每域可编译可测。
todos:
- id: phase-a-diagnostics
content: Phase AStartup 诊断 + HostStartupMonitor 独立类 + AOT 启动检测竞态修复 + 测试
status: completed
- id: phase-b-directory
content: Phase B1职责域目录迁移Deployment/Update/Startup/Oobe/Plugins/Infrastructure零逻辑变更提交
status: completed
- id: phase-b-pipeline
content: Phase B2RunAsync→LaunchPipeline+ILaunchPhase引入 LauncherOrchestrator删除 LauncherFlowCoordinator提交
status: completed
- id: phase-b-app-slim
content: Phase B3App.axaml.cs 精简为纯 Avalonia 初始化 + 委托 LauncherOrchestrator提交
status: completed
- id: phase-c-di
content: Phase CLauncherServiceRegistration + 轻量 MS DI统一 CLI/GUI 装配,提交
status: completed
- id: phase-d-update-split
content: Phase DUpdateEngineService→门面+策略类Verifier/Activator/Rollback 等),提交
status: completed
- id: phase-e-guardrails
content: Phase ELauncherArchitectureTests + 文档 + AOT 回归,提交
status: completed
isProject: false
---
# Launcher 单项目内部解耦改造计划(执行版)
## 0. 硬性约束
| 约束 | 说明 |
| ------------ | --------------------------------------------------------------------------------------------------------------------------------------------------------- |
| **单项目** | 仅 `[LanMountainDesktop.Launcher/LanMountainDesktop.Launcher.csproj](LanMountainDesktop.Launcher/LanMountainDesktop.Launcher.csproj)`,不新建 Launcher.* 独立程序集 |
| **单 exe** | 仍只发布 `LanMountainDesktop.Launcher.exe`AOT 单文件) |
| **零部署风险** | 不改变安装包目录结构、不引入新进程、不改变 Public IPC / Coordinator IPC 拓扑与契约 |
| **增量重构** | 一个职责域一域推进,每步 `dotnet build` + 相关 `dotnet test` 通过后再进下一步 |
| **单进程性能** | 模块间仅 in-process 接口调用,不为解耦新增 IPC |
| **未来可拆** | 各域暴露 `I`* 接口,将来若需多进程可直接复用契约 |
| **Git 自主提交** | Agent 在每个职责域完成且验证通过后 **自动 commit**,无需用户手动提交(见 §8 |
外部共享库 `[LanMountainDesktop.PluginPackaging](LanMountainDesktop.PluginPackaging/)` 保留Host + Launcher CLI 共用),不属于 Launcher 拆分。
---
## 1. 验收标准(必须全部满足)
### 1.1 零部署风险
- Inno Setup / CI 产物仍只有:`LanMountainDesktop.Launcher.exe` + `app-{version}/` + `.launcher/`
- Host 调用 Launcher 的 CLI 参数、`launch-source``apply-update` 路径不变
- Public IPC routes`lanmountain.launcher.startup-progress``loading-state`)与 Coordinator pipe 不变
- VeloPack / 更新 apply 状态机(`.current/.partial/.destroy`)行为不变
### 1.2 增量可验证
- 每个 Phase 结束:编译绿 + 该域新增/既有测试绿
- 允许「纯移动文件」的 PR 单独提交,行为 diff 为零
### 1.3 测试友好
- `Startup/``Update/``Deployment/` 内类型 **无 Avalonia 依赖**,可独立单元测试
- 每个 `ILaunchPhase`、每个 Update 策略类各有对应测试类
- 保留并扩展现有 `[LauncherStartupTimeoutPolicyTests](LanMountainDesktop.Tests/LauncherStartupTimeoutPolicyTests.cs)``[LauncherMultiInstancePolicyTests](LanMountainDesktop.Tests/LauncherMultiInstancePolicyTests.cs)`
### 1.4 启动性能
- Pipeline 阶段为同步/异步方法调用链,不引入额外进程或网络
- DI 容器仅在进程入口构建一次Stage/Phase 实例可复用 Singleton
### 1.5 代码结构目标
| 对象 | 当前(实测) | 目标 |
| ----------------------------------- | -------------------------------------------- | --------------------------------------------------- |
| `LauncherFlowCoordinator` 全 partial | ~1880 行859+568+279+…) | **删除**;逻辑迁入 Pipeline + Phases |
| `RunAsync()` 等价逻辑 | 跨 partial ~800+ 行 while/阶段混杂 | **≤80 行** 编排入口,细节在各 Phase |
| `UpdateEngineService` | ~1622 行 | 门面 **≤200 行** + 6 个策略类各 **≤300 行** |
| `App.axaml.cs` | ~258 行(已部分瘦身) | **≤120 行**:纯 Avalonia + 一行委托 `LauncherOrchestrator` |
| `LauncherOrchestrator` | 不存在(逻辑在 Coordinator + CompositionRoot 546 行) | **≤250 行**GUI 入口编排 |
| `LauncherCompositionRoot` | ~546 行 | **≤150 行**:仅 DI 构建 + 入口分发 |
---
## 2. 目标架构
### 2.1 核心类型关系
```mermaid
flowchart TB
Program --> EntryRouter
App --> LauncherOrchestrator
EntryRouter --> LauncherOrchestrator
LauncherOrchestrator --> LaunchPipeline
LaunchPipeline --> Phase1[CleanupPhase]
LaunchPipeline --> Phase2[OobeGatePhase]
LaunchPipeline --> Phase3[ApplyUpdatePhase]
LaunchPipeline --> Phase4[LaunchHostPhase]
LaunchPipeline --> Phase5[MonitorStartupPhase]
Phase3 --> IUpdateEngine
Phase4 --> IDeploymentLocator
Phase5 --> IHostStartupMonitor
LauncherCompositionRoot --> ServiceProvider
ServiceProvider --> LaunchPipeline
```
**命名约定:**
- `**LauncherOrchestrator`**GUI 生命周期内的唯一编排入口(取代 `LauncherFlowCoordinator` 对外角色)
- `**LaunchPipeline**`:按序执行 `ILaunchPhase` 列表
- `**ILaunchPhase**`:原 `ILaunchPipelineStage`;每个 Phase 对应原 `RunAsync` 中一个职责段
### 2.2 职责域目录(单项目内)
```
LanMountainDesktop.Launcher/
├── Program.cs # CLI / GUI 路由
├── App.axaml.cs # 纯 Avalonia≤120 行)
├── Shell/
│ ├── LauncherOrchestrator.cs # GUI 编排入口
│ ├── LauncherCompositionRoot.cs # DI + Entry 分发
│ ├── LaunchPipeline.cs
│ ├── Phases/ # ILaunchPhase 实现
│ │ ├── CleanupDeploymentsPhase.cs
│ │ ├── OobeGatePhase.cs
│ │ ├── ApplyPendingUpdatePhase.cs
│ │ ├── LaunchHostPhase.cs
│ │ └── MonitorStartupPhase.cs
│ └── EntryHandlers/ # apply-update / air-app-broker / attach
├── Deployment/
├── Update/
│ ├── IUpdateEngine.cs
│ ├── UpdateEngineFacade.cs # 原 UpdateEngineService 门面
│ └── Strategies/
│ ├── PendingUpdateDetector.cs
│ ├── UpdatePackageVerifier.cs
│ ├── DeploymentActivator.cs
│ ├── UpdateSnapshotStore.cs
│ ├── RollbackStrategy.cs
│ └── IncomingArtifactsCleaner.cs
├── Startup/
├── Oobe/
├── Ipc/
├── AirApp/
├── Plugins/
├── Infrastructure/
├── Models/
└── Views/
```
### 2.3 模块依赖规则
- `Deployment/``Update/``Startup/`**禁止** `using Avalonia`
- `Views/`**禁止** 引用具体 `UpdateEngineFacade` / `DeploymentLocator`(仅接口或 Orchestrator
- 跨域:**仅通过 `I`* 接口**Orchestrator/Pipeline 负责装配
### 2.4 与 Host 边界(不变)
| 能力 | Owner |
| -------------------------- | ------------------------------ |
| OOBE / Splash / 多实例 / 启动检测 | Launcher `Startup/` + `Shell/` |
| 更新 apply / rollback | Launcher `Update/` |
| 插件市场 / pending | Host + PluginPackaging |
| 更新 download | Host → spawn Launcher apply |
---
## 3. 三大核心拆分(用户指定)
### 3.1 拆分 `LauncherFlowCoordinator``RunAsync` → Pipeline + Phase
**现状:** 逻辑分散在 4 个 partial等效一个 1800+ 行 God Class`RunAsync` 内含清理、OOBE、更新、启动、IPC 监听、超时 while-loop、多实例分支。
**目标 API单项目 `Shell/` 内):**
```csharp
internal interface ILaunchPhase
{
string PhaseId { get; }
/// <returns>null = 继续下一阶段;非 null = 管道终止并返回结果</returns>
Task<LauncherResult?> ExecuteAsync(LaunchContext context, CancellationToken cancellationToken);
}
internal sealed class LaunchPipeline
{
public LaunchPipeline(IEnumerable<ILaunchPhase> phases) { ... }
public Task<LauncherResult> RunAsync(LaunchContext context, CancellationToken ct);
}
```
**Phase 映射(与原 RunAsync 步骤一一对应):**
| Phase | 原 RunAsync 段 | 产出 |
| ------------------------- | --------------------------------------- | ----------------------------- |
| `CleanupDeploymentsPhase` | `CleanupOldDeployments` | 无 UI |
| `ExistingHostProbePhase` | 多实例 / Public IPC 探测 | 可短路成功 |
| `ApplyPendingUpdatePhase` | `_updateEngine.ApplyPendingUpdateAsync` | 失败仍继续 |
| `OobeGatePhase` | migration + OOBE steps | UI via `ILauncherUiPresenter` |
| `LaunchHostPhase` | `LaunchHostWithIpcAsync` | Process + plan |
| `MonitorStartupPhase` | while-loop + IPC + timeout | 调用 `IHostStartupMonitor` |
`**LauncherOrchestrator` 职责:**
- 接收 `SplashWindow`、构建 `LaunchContext`(含 reporter、attempt registry、coordinator server
- 调用 `LaunchPipeline.RunAsync`
- 管理 Splash/Error 窗口生命周期(委托 `ILauncherUiPresenter`
- **不含** 更新/部署/IPC 细节
**删除清单:** `LauncherFlowCoordinator.cs` 及全部 partial 文件。
---
### 3.2 拆分 `UpdateEngineService` → 门面 + 策略类
**现状:** ~1622 行单文件,混合检测、验签、解压、激活、快照、回滚、清理。
**目标结构:**
```
Update/
├── IUpdateEngine.cs # 对外契约(未来多进程可原样抽出)
├── UpdateEngineFacade.cs # 门面编排策略≤200 行
└── Strategies/
├── IUpdateStrategy.cs # 可选:各策略统一接口
├── PendingUpdateDetector.cs # CheckPendingUpdate
├── UpdatePackageVerifier.cs # manifest + RSA 签名
├── UpdatePackageExtractor.cs # 解压 / 增量复用
├── DeploymentActivator.cs # .current / .partial / .destroy
├── UpdateSnapshotStore.cs # snapshots 读写
├── RollbackStrategy.cs # rollback CLI/GUI
└── IncomingArtifactsCleaner.cs # CleanupIncomingArtifacts
```
**门面方法映射:**
| 原 `UpdateEngineService` 公开方法 | 委托策略 |
| ---------------------------- | ------------------------------------------------------ |
| `CheckPendingUpdate()` | `PendingUpdateDetector` |
| `ApplyPendingUpdateAsync()` | Detector → Verifier → Extractor → Activator → Snapshot |
| `RollbackLatest()` | `RollbackStrategy` |
| `CleanupIncomingArtifacts()` | `IncomingArtifactsCleaner` |
| `DownloadAsync()`(若有) | 保持或拆 `UpdateDownloader` |
**测试:** 每个 Strategy 独立 mock `IDeploymentLocator` / 文件系统,不启 Avalonia。
---
### 3.3 精简 `App.axaml.cs` → 纯 Avalonia + `LauncherOrchestrator`
**现状:** ~258 行,仍含 apply-update、air-app-broker、preview、coordinator attach 等分支。
**目标结构:**
```csharp
// App.axaml.cs 目标形态(概念)
public override void OnFrameworkInitializationCompleted()
{
if (ApplicationLifetime is IClassicDesktopStyleApplicationLifetime desktop)
{
var context = LauncherRuntimeContext.Current;
var mode = LauncherEntryModeResolver.Resolve(context);
_ = LauncherOrchestrator.RunAsync(desktop, context, mode);
}
base.OnFrameworkInitializationCompleted();
}
```
**从 App 迁出的逻辑 → `Shell/EntryHandlers/`**
| 现 App 分支 | 新 Handler |
| ----------------- | -------------------------------------- |
| `launch` + splash | `GuiLaunchEntryHandler` → Orchestrator |
| `apply-update` | `ApplyUpdateEntryHandler` |
| `air-app-broker` | `AirAppBrokerEntryHandler` |
| debug preview | `PreviewEntryHandler` |
**验收:** `App.axaml.cs` ≤120 行;不含 `new UpdateEngineService` / `new DeploymentLocator` / while-loop。
---
## 4. 分阶段执行顺序与 Git 提交点
```mermaid
flowchart LR
A[Phase A Startup] --> B1[Phase B1 目录迁移]
B1 --> B2[Phase B2 Pipeline+Orchestrator]
B2 --> B3[Phase B3 App 精简]
B3 --> C[Phase C DI]
B1 --> D[Phase D Update 策略拆分]
C --> E[Phase E 守卫+文档+AOT回归]
D --> E
```
### Phase AStartup 子系统 + AOT 生产 bug优先
- 抽出 `Startup/HostStartupMonitor.cs`(从 partial 独立)
- 修复 IPC 连接退避、成功判定统一走 `StartupSuccessTracker`
- Host 侧 `DesktopVisible` 上报对齐(仅日志/时序,不改 IPC 契约)
- 测试 + `**git commit**`: `fix(launcher): extract HostStartupMonitor and harden startup detection`
### Phase B1目录迁移零逻辑变更
- 物理移动文件到 `Deployment/``Update/``Startup/` 等,更新 namespace
- `dotnet build` + test
- `**git commit**`: `refactor(launcher): reorganize into responsibility folders`
### Phase B2Pipeline + Phase + LauncherOrchestrator
- 实现 `ILaunchPhase``LaunchPipeline``LauncherOrchestrator`
- 逐 Phase 从 Coordinator 迁移逻辑(可先并行运行对照测试)
- 删除 `LauncherFlowCoordinator*`
- `**git commit**`: `refactor(launcher): replace LauncherFlowCoordinator with LaunchPipeline`
### Phase B3App.axaml.cs 精简
- EntryHandlers 提取App 仅 Avalonia + Orchestrator 委托
- `**git commit**`: `refactor(launcher): slim App.axaml.cs to Avalonia shell only`
### Phase C轻量 DI
- `LauncherServiceRegistration.cs` + `Microsoft.Extensions.DependencyInjection`
- Program / CliHost / CompositionRoot 统一 `ServiceProvider`
- `**git commit**`: `refactor(launcher): add composition-root DI wiring`
### Phase DUpdateEngine 策略拆分(可与 B2 并行,依赖 B1
- 策略类提取 + `UpdateEngineFacade`
- 删除原巨型 `UpdateEngineService.cs`
- 每策略测试
- `**git commit**`: `refactor(launcher): split UpdateEngine into strategy classes`
### Phase E守卫 + 文档 + AOT 回归
- `LauncherArchitectureTests`(命名空间依赖规则)
- 更新 `[docs/LAUNCHER.md](docs/LAUNCHER.md)``[.trae/specs/launcher-shell-hardening/spec.md](.trae/specs/launcher-shell-hardening/spec.md)`
- AOT publish 本地 smokelaunch / apply-update / 多实例 / 启动检测
- `**git commit**`: `docs(launcher): document module boundaries and add architecture tests`
---
## 5. Phase / Service 测试矩阵
| 组件 | 测试文件 | 覆盖点 |
| ----------------------- | ---------------------------- | --------------------------------- |
| `StartupSuccessTracker` | `StartupSuccessTrackerTests` | Foreground/Tray/Background policy |
| `HostStartupMonitor` | `HostStartupMonitorTests` | 超时、IPC 延迟、ShellStatus 轮询 |
| `LaunchPipeline` | `LaunchPipelineTests` | Phase 短路、失败传播 |
| 各 `ILaunchPhase` | `*PhaseTests` | 单阶段 mock |
| `PendingUpdateDetector` | `PendingUpdateDetectorTests` | 无 pending / corrupt |
| `DeploymentActivator` | `DeploymentActivatorTests` | 标记文件状态机 |
| `RollbackStrategy` | `RollbackStrategyTests` | 快照回退 |
| 命名空间规则 | `LauncherArchitectureTests` | 无 Avalonia 泄漏 |
---
## 6. 明确不做
- 不新建 csprojLauncher.Deployment 等)
- 不新建 exe / Windows Service
- 不改变 Public IPC / Coordinator IPC 协议
- 不把插件市场安装迁回 Launcher
- 不为模块间通信引入新 IPC仅保留现有 Host↔Launcher 契约)
---
## 7. 风险与缓解
| 风险 | 缓解 |
| --------------- | ------------------------------------------------------------------ |
| 大规模移动 merge 冲突 | B1 独立 commit零逻辑变更 |
| Pipeline 迁移行为回归 | 先写 Phase 级测试再迁代码;保留 `LMD_LAUNCHER_LEGACY_COORDINATOR=1` 开关一个版本(可选) |
| AOT + DI | 显式注册,禁止反射扫描;`PublishAot` CI 步骤验证 |
| Update 拆分遗漏路径 | CLI `update *` 与 GUI apply-update 同一 `IUpdateEngine` 门面 |
---
## 8. Git 工作流Agent 自主提交)
**原则:** 每个 Phase 验证通过后立即提交;不累积巨型 uncommitted diff。
**Commit 前检查(每个 commit 必做):**
```bash
dotnet build LanMountainDesktop.slnx -c Debug
dotnet test LanMountainDesktop.slnx -c Debug --filter "FullyQualifiedName~Launcher"
```
**Commit message 风格(与仓库一致):**
```
refactor(launcher): replace LauncherFlowCoordinator with LaunchPipeline
Pipeline + Phase pattern; LauncherOrchestrator becomes GUI entry.
No deployment or IPC contract changes.
```
**禁止:** `git push --force`、修改 git config、跳过 hooks除非 hook 失败需修复后新 commit
**建议分支:** `refactor/launcher-internal-modularization`(单 long-lived 分支,按 Phase 连续 commit或每 Phase 一个 PR 由用户决定 merge 时机)。
---
## 9. 整体完成定义Definition of Done
-`LauncherFlowCoordinator` 源文件
- `App.axaml.cs` ≤120 行,仅 Avalonia + Orchestrator 委托
- `UpdateEngineService` 巨型文件已替换为 Facade + Strategies
- 职责域目录就位,架构测试通过
- 全量 Launcher 相关测试 + AOT publish smoke 通过
- 安装包结构与 IPC 拓扑与重构前一致
- 每个 Phase 有对应 Git commit工作区 clean

View File

@@ -1,34 +0,0 @@
---
name: Bug Report
about: Create a report to help us improve
title: "[BUG] "
labels: bug
assignees: ''
---
## Describe the bug
A clear and concise description of what the bug is.
## Expected behavior
What did you expect to happen?
## Actual behavior
What actually happened?
## Steps to reproduce
1. Go to '...'
2. Click on '....'
3. Scroll down to '....'
4. See error
## Environment
- OS: [e.g. Windows 10, Windows 11]
- Version: [e.g. 1.0.0]
- .NET Version: [e.g. 10.0]
## Screenshots
If applicable, add screenshots to help explain your problem.
## Additional context
Add any other context about the problem here.

122
.github/ISSUE_TEMPLATE/bug_report.yml vendored Normal file
View File

@@ -0,0 +1,122 @@
name: Bug 反馈 / Bug report
description: 报告 LanMountainDesktop 宿主、启动器、插件运行时、SDK 或共享契约中的可复现问题。
title: "[Bug] "
labels: ["bug"]
body:
- type: markdown
attributes:
value: |
感谢反馈问题。请用一句话写清标题,并尽量为每个独立 Bug 单独创建一个 Issue。
Thank you for reporting a bug. Please use a clear title and open one issue for each independent bug.
> [!IMPORTANT]
> 请不要上传未脱敏的日志、截图或配置。移除 token、密钥、Cookie、账号、学生/班级个人信息、绝对隐私路径等敏感内容。
>
> Do not share unredacted logs, screenshots, or configs. Remove tokens, secrets, cookies, accounts, student/class personal data, and private local paths.
- type: checkboxes
id: checklist
attributes:
label: 提交前检查 / Pre-flight checklist
description: 提交前请确认以下事项。
options:
- label: 我已经搜索过现有 Issues确认没有重复反馈。 / I searched existing issues and found no duplicate.
required: true
- label: 我已经确认该问题属于 LanMountainDesktop 仓库边界,而不是插件市场元数据或官方示例插件实现。 / I confirmed this belongs to LanMountainDesktop, not marketplace metadata or the sample plugin implementation.
required: true
- label: 我已尽量使用最新版本、最新构建或最新提交验证问题仍然存在。 / I reproduced this on the latest release, build, or commit available to me.
required: true
- label: 我已对所有附件和日志做脱敏处理。 / I redacted sensitive information from all attachments and logs.
required: true
- type: dropdown
id: area
attributes:
label: 影响区域 / Affected area
description: 选择最接近的问题区域。
options:
- 桌面宿主 / Desktop host
- 启动器、更新或打包 / Launcher, update, or packaging
- AirApp Runtime
- 插件运行时或安装 / Plugin runtime or installation
- Plugin SDK 或共享契约 / Plugin SDK or shared contracts
- 设置、主题或外观 / Settings, theme, or appearance
- 桌面组件系统 / Desktop component system
- 构建、测试或 CI / Build, test, or CI
- 文档 / Documentation
- 不确定 / Not sure
validations:
required: true
- type: textarea
id: summary
attributes:
label: 问题描述 / Summary
description: 清楚说明发生了什么,以及它为什么是问题。
placeholder: |
例如:打开设置窗口后,点击“外观”页会导致应用崩溃。
Example: Opening the Appearance settings page crashes the app.
validations:
required: true
- type: textarea
id: expected
attributes:
label: 期望行为 / Expected behavior
description: 说明你原本期望发生什么。
validations:
required: true
- type: textarea
id: actual
attributes:
label: 实际行为 / Actual behavior
description: 说明实际发生了什么,包括错误提示、异常表现或回归点。
validations:
required: true
- type: textarea
id: steps
attributes:
label: 复现步骤 / Steps to reproduce
description: 请提供能让维护者复现问题的最小步骤。
placeholder: |
1. 启动应用
2. 打开……
3. 点击……
4. 看到……
1. Launch the app
2. Open ...
3. Click ...
4. See ...
validations:
required: true
- type: textarea
id: environment
attributes:
label: 环境信息 / Environment
description: 请尽量完整填写。可粘贴 `dotnet --info` 中和问题相关的部分。
value: |
- OS / 操作系统:
- LanMountainDesktop version / 应用版本:
- Build channel or commit / 构建渠道或提交:
- .NET SDK / Runtime
- Install mode / 安装方式(源码运行、安装包、便携版等):
- Plugin SDK version if relevant / 如涉及插件SDK 版本:
validations:
required: true
- type: textarea
id: logs
attributes:
label: 日志、堆栈或截图 / Logs, stack traces, or screenshots
description: 请粘贴已脱敏的日志、异常堆栈,或附上截图/录屏。大文件请打包后通过 GitHub 附件上传。
render: shell
- type: textarea
id: extra
attributes:
label: 补充信息 / Additional context
description: 例如是否只在某个插件、主题、显示器缩放、系统语言或更新通道下出现。
- type: checkboxes
id: final
attributes:
label: 最后确认 / Final confirmation
options:
- label: 我确认以上信息足够维护者理解并尝试复现问题。 / I confirm the information above is enough for maintainers to understand and try to reproduce the issue.
required: true

11
.github/ISSUE_TEMPLATE/config.yml vendored Normal file
View File

@@ -0,0 +1,11 @@
blank_issues_enabled: true
contact_links:
- name: 插件市场元数据 / Plugin marketplace metadata
url: https://github.com/wwiinnddyy/LanAirApp/issues/new
about: 插件市场索引、生态材料、开发者门户内容请在 LanAirApp 仓库反馈。 / Report marketplace index, ecosystem materials, and developer portal content in LanAirApp.
- name: 官方示例插件 / Official sample plugin
url: https://github.com/wwiinnddyy/LanMountainDesktop.SamplePlugin/issues/new
about: 示例插件实现、示例包发布和示例插件使用问题请在 SamplePlugin 仓库反馈。 / Report sample plugin implementation, packages, and usage in the SamplePlugin repo.
- name: 贡献指南 / Contribution guide
url: https://github.com/wwiinnddyy/LanMountainDesktop/blob/main/docs/CONTRIBUTING.md
about: 提交 PR 前请阅读贡献、文档和 spec 更新规则。 / Read contribution, documentation, and spec update rules before opening a PR.

View File

@@ -1,36 +0,0 @@
---
name: Config Issue
about: Report configuration or build issues
title: "[CONFIG] "
labels: configuration
assignees: ''
---
## Describe the configuration issue
A clear description of the configuration or build problem.
## Environment Details
- OS: [e.g. Windows 10/11, Linux, macOS]
- .NET SDK Version: [output of `dotnet --version`]
- Visual Studio Version: [if applicable]
- Project Configuration: [e.g., Debug/Release]
## Steps to reproduce
1. ...
2. ...
## Expected result
What should happen?
## Actual result
What actually happens?
## Configuration files
If applicable, share relevant configuration:
- `.csproj` settings (without sensitive data)
- Build parameters
- Environment variables set
## Additional context
Add any other relevant information.

111
.github/ISSUE_TEMPLATE/config_issue.yml vendored Normal file
View File

@@ -0,0 +1,111 @@
name: 配置、构建或打包问题 / Configuration, build, or packaging issue
description: 报告还原、构建、测试、运行、打包、CI 或环境配置相关问题。
title: "[Config] "
labels: ["configuration"]
body:
- type: markdown
attributes:
value: |
这个模板用于环境、构建、测试、运行和打包问题。如果问题是应用运行后的具体功能异常,请优先使用 Bug 反馈。
Use this template for environment, build, test, run, and packaging issues. For runtime feature bugs, prefer the Bug report template.
> [!IMPORTANT]
> 请不要公开 NuGet 源凭据、签名证书、API token、私有路径、机器名、用户名或其他敏感配置。
>
> Do not expose NuGet credentials, signing certificates, API tokens, private paths, machine names, usernames, or other sensitive configuration.
- type: checkboxes
id: checklist
attributes:
label: 提交前检查 / Pre-flight checklist
options:
- label: 我已经阅读过 `docs/DEVELOPMENT.md` 中对应的构建、运行或测试说明。 / I read the relevant build, run, or test instructions in `docs/DEVELOPMENT.md`.
required: true
- label: 我已经运行过 `dotnet restore`,或说明了为什么无法运行。 / I ran `dotnet restore`, or explained why I could not.
required: true
- label: 我已经搜索过现有 Issues确认没有重复问题。 / I searched existing issues and found no duplicate.
required: true
- label: 我已对日志、路径和配置片段做脱敏处理。 / I redacted sensitive data from logs, paths, and config snippets.
required: true
- type: dropdown
id: category
attributes:
label: 问题类型 / Issue type
options:
- dotnet restore
- dotnet build
- dotnet test
- dotnet run
- Launcher 启动或维护命令 / Launcher startup or maintenance command
- 插件包生成 / Plugin package generation
- Windows 安装包或发布产物 / Windows installer or release artifact
- GitHub Actions / CI
- NuGet、SDK 或依赖版本 / NuGet, SDK, or dependency version
- 其他 / Other
validations:
required: true
- type: textarea
id: command
attributes:
label: 执行的命令 / Command executed
description: 请粘贴触发问题的最小命令。
render: shell
placeholder: |
dotnet build LanMountainDesktop.slnx -c Debug
validations:
required: true
- type: textarea
id: expected
attributes:
label: 期望结果 / Expected result
description: 你期望命令或流程产生什么结果?
validations:
required: true
- type: textarea
id: actual
attributes:
label: 实际结果 / Actual result
description: 实际输出、错误码、失败阶段或 CI 链接。
validations:
required: true
- type: textarea
id: environment
attributes:
label: 环境信息 / Environment
description: 请尽量完整填写。可粘贴 `dotnet --info` 中和问题相关的部分。
value: |
- OS / 操作系统:
- Shell / 终端:
- `dotnet --version`
- `dotnet --info` relevant parts / 相关片段:
- Repository branch or commit / 仓库分支或提交:
- Configuration / 构建配置Debug/Release
- Architecture / 架构x64/arm64 等):
validations:
required: true
- type: textarea
id: logs
attributes:
label: 已脱敏日志 / Redacted logs
description: 请粘贴关键错误日志。长日志建议只贴失败段落,或通过 GitHub 附件上传。
render: shell
validations:
required: true
- type: textarea
id: config
attributes:
label: 相关配置片段 / Relevant config snippets
description: 如 `.csproj`、`Directory.Packages.props`、workflow、环境变量名等。请先脱敏不要粘贴真实密钥。
render: xml
- type: textarea
id: extra
attributes:
label: 补充信息 / Additional context
description: 例如是否只在某个平台、某个 runner、某个 NuGet 源或某个安装路径下出现。
- type: checkboxes
id: final
attributes:
label: 最后确认 / Final confirmation
options:
- label: 我确认以上信息足够维护者定位失败阶段,并且没有包含敏感配置。 / I confirm the information above is enough to identify the failing stage and contains no sensitive configuration.
required: true

View File

@@ -1,25 +0,0 @@
---
name: Feature Request
about: Suggest an idea for this project
title: "[FEATURE] "
labels: enhancement
assignees: ''
---
## Is your feature request related to a problem?
A clear and concise description of what the problem is. Ex. I'm always frustrated when [...]
## Describe the solution you'd like
A clear and concise description of what you want to happen.
## Describe alternatives you've considered
A clear and concise description of any alternative solutions or features you've considered.
## Additional context
Add any other context or screenshots about the feature request here.
## Priority
- [ ] Low - Nice to have
- [ ] Medium - Would improve usability
- [ ] High - Essential feature

View File

@@ -0,0 +1,103 @@
name: 功能请求 / Feature request
description: 提出新的能力、体验优化或行为调整建议。
title: "[Feature] "
labels: ["enhancement"]
body:
- type: markdown
attributes:
value: |
感谢提出想法。请尽量描述真实场景和目标用户,而不是只描述一个实现方案。
Thanks for the idea. Please describe the real user scenario and target users, not only a proposed implementation.
> [!IMPORTANT]
> 如果是多项功能,请分别创建 Issue。若需求更适合插件市场、官方示例插件或第三方插件实现请转到对应仓库或讨论区。
>
> Please open separate issues for separate features. If the request belongs to marketplace metadata, sample plugins, or third-party plugins, use the related repository or discussion channel.
- type: checkboxes
id: checklist
attributes:
label: 提交前检查 / Pre-flight checklist
options:
- label: 我已经搜索过现有 Issues 和 `.trae/specs/`,确认没有相同或高度相似的需求。 / I searched existing issues and `.trae/specs/` and found no same or highly similar request.
required: true
- label: "我已经确认该需求属于本仓库边界桌面宿主、插件运行时、Plugin SDK、共享契约、外观或设置基础设施。 / I confirmed this belongs to this repo: desktop host, plugin runtime, Plugin SDK, shared contracts, appearance, or settings infrastructure."
required: true
- label: 我已考虑该能力是否可以由插件实现,并在下方说明。 / I considered whether this can be implemented as a plugin and explain it below.
required: true
- type: dropdown
id: area
attributes:
label: 需求区域 / Request area
options:
- 桌面宿主体验 / Desktop host UX
- 启动器、更新或安装 / Launcher, update, or installation
- AirApp Runtime
- 插件运行时或安装 / Plugin runtime or installation
- Plugin SDK 或共享契约 / Plugin SDK or shared contracts
- 设置、主题或外观 / Settings, theme, or appearance
- 桌面组件系统 / Desktop component system
- 开发、构建或 CI / Development, build, or CI
- 文档 / Documentation
- 不确定 / Not sure
validations:
required: true
- type: textarea
id: problem
attributes:
label: 背景与问题 / Background and problem
description: 你遇到了什么限制、低效或不清楚的地方?谁会受影响?
placeholder: |
例如:插件开发者在调试安装流程时无法判断包签名失败还是复制失败。
Example: Plugin developers cannot tell whether an install failure is caused by package signature validation or file copying.
validations:
required: true
- type: textarea
id: proposal
attributes:
label: 想要的结果 / Desired outcome
description: 描述你希望用户或开发者最终能完成什么。
validations:
required: true
- type: textarea
id: alternatives
attributes:
label: 已考虑的替代方案 / Alternatives considered
description: 是否可以通过现有设置、插件、脚本、文档或外部仓库解决?为什么仍需要本仓库改动?
validations:
required: true
- type: textarea
id: scope
attributes:
label: 范围、边界与兼容性 / Scope, boundaries, and compatibility
description: 是否涉及 UI、设置持久化、Plugin SDK、共享契约、迁移、跨平台行为或破坏性变更
placeholder: |
- 是否需要更新 `.trae/specs/<feature>/`
- 是否影响已有插件或用户配置
- 是否仅适用于 Windows/Linux/macOS 某个平台
validations:
required: true
- type: textarea
id: references
attributes:
label: 参考资料、截图或草图 / References, screenshots, or sketches
description: 可附上截图、录屏、草图、相关 PR、文档链接或类似产品参考。
- type: dropdown
id: priority
attributes:
label: 优先级感知 / Priority signal
description: 这不是维护者承诺,仅帮助 triage。
options:
- 低:有帮助但不紧急 / Low: useful but not urgent
- 中:明显改善主要流程 / Medium: improves a main workflow
- 高:阻塞使用或开发 / High: blocks usage or development
validations:
required: true
- type: checkboxes
id: final
attributes:
label: 最后确认 / Final confirmation
options:
- label: 我确认这个请求描述的是一个清晰、可讨论的目标,而不是多个无关需求的集合。 / I confirm this request describes a clear discussable goal, not a bundle of unrelated requests.
required: true

View File

@@ -1,34 +1,92 @@
## Description
Please include a summary of the changes and related context. Describe the "why" behind your changes.
<!--
感谢贡献 LanMountainDesktop。
Thank you for contributing to LanMountainDesktop.
## Type of change
- [ ] Bug fix (non-breaking change which fixes an issue)
- [ ] New feature (non-breaking change which adds functionality)
- [ ] Breaking change (fix or feature that would cause existing functionality to change)
- [ ] Documentation update
请不要在 PR、截图、日志或测试数据中提交 token、密钥、Cookie、真实账号、学生/班级个人信息或其他敏感内容。
Do not include tokens, secrets, cookies, real accounts, student/class personal data, or other sensitive information in this PR, screenshots, logs, or test data.
-->
## Related Issues
Fixes #(issue number)
## 这个 PR 做了什么? / What does this PR do?
## Testing
Please describe the testing you've done to verify the changes:
- [ ] Built successfully
- [ ] Tested on Windows
- [ ] No new warnings or errors introduced
- [ ] Backward compatible
<!--
用 2-5 句话说明改动内容和原因。请说明用户、开发者或维护者能得到什么。
Describe the change and the reason in 2-5 sentences. Mention what users, developers, or maintainers get from it.
-->
## Screenshots/Videos (if applicable)
If your changes include UI modifications, please attach screenshots or videos.
## 相关 Issue / Related issues
## Checklist
- [ ] My code follows the project's style guidelines
- [ ] I have performed a self-review of my own code
- [ ] I have commented my code, particularly in hard-to-understand areas
- [ ] I have made corresponding changes to the documentation
- [ ] My changes generate no new warnings
- [ ] I have tested my changes thoroughly
- [ ] New and existing unit tests pass locally with my changes
- [ ] I have added tests that prove my fix is effective or that my feature works
<!--
如果可以关闭 Issue请使用
Fixes #123
## Additional context
Add any other context about the PR here.
If this closes an issue, use:
Fixes #123
-->
## 影响范围 / Affected areas
<!-- 勾选所有适用项。Check all that apply. -->
- [ ] 桌面宿主 / Desktop host
- [ ] 启动器、更新或安装 / Launcher, update, or installation
- [ ] AirApp Runtime
- [ ] 插件运行时或安装 / Plugin runtime or installation
- [ ] Plugin SDK 或共享契约 / Plugin SDK or shared contracts
- [ ] 设置、主题或外观 / Settings, theme, or appearance
- [ ] 桌面组件系统 / Desktop component system
- [ ] 构建、测试、CI 或打包 / Build, test, CI, or packaging
- [ ] 文档或规格 / Documentation or specs
## 行为、兼容性与迁移 / Behavior, compatibility, and migration
<!--
说明是否改变用户可见行为、设置持久化、文件格式、公共 API、Plugin SDK、共享契约、打包产物或跨平台行为。
如果没有,请写“无 / None”。
Describe whether this changes user-visible behavior, persisted settings, file formats, public APIs, Plugin SDK, shared contracts, packaged artifacts, or cross-platform behavior.
If not, write "无 / None".
-->
## 验证 / Verification
<!-- 勾选已完成项并在下面补充实际命令、平台和结果。Check completed items and add commands, platforms, and results below. -->
- [ ] `dotnet restore`
- [ ] `dotnet build LanMountainDesktop.slnx -c Debug`
- [ ] `dotnet test LanMountainDesktop.slnx -c Debug`
- [ ] 手动运行桌面宿主 / Manually ran the desktop host
- [ ] 验证插件安装、加载或 SDK 场景 / Verified plugin install, loading, or SDK scenarios
- [ ] 验证 Windows / Verified on Windows
- [ ] 验证 Linux / Verified on Linux
- [ ] 验证 macOS / Verified on macOS
- [ ] 未能运行的检查已说明原因 / Explained any checks that could not be run
实际验证说明 / Verification details:
```text
```
## 文档与 spec / Documentation and specs
<!-- 勾选所有适用项。Check all that apply. -->
- [ ] 本 PR 不需要更新文档或 `.trae/specs/` / No documentation or `.trae/specs/` update is needed
- [ ] 已更新权威文档 / Updated source-of-truth documentation
- [ ] 已新增或更新 `.trae/specs/<feature>/` / Added or updated `.trae/specs/<feature>/`
- [ ] 已更新 SDK 迁移说明或共享契约说明 / Updated SDK migration or shared contract notes
## UI 截图或录屏 / UI screenshots or videos
<!--
涉及 UI、主题、设置页、窗口生命周期或组件外观时请附截图或录屏。
Attach screenshots or videos when changing UI, theme, settings pages, window lifecycle, or component appearance.
-->
## 最终检查 / Final checklist
- [ ] 我已自查代码和文档,移除了调试残留和无关改动。 / I self-reviewed the code and docs and removed debug leftovers and unrelated changes.
- [ ] 我没有提交未脱敏的日志、凭据或个人信息。 / I did not commit unredacted logs, credentials, or personal information.
- [ ] 如果改动涉及 UI已遵守 `docs/VISUAL_SPEC.md``docs/CORNER_RADIUS_SPEC.md`。 / If this changes UI, it follows `docs/VISUAL_SPEC.md` and `docs/CORNER_RADIUS_SPEC.md`.
- [ ] 如果改动涉及行为、流程、边界或命令,已同步对应文档。 / If this changes behavior, workflows, boundaries, or commands, the related docs are updated.
- [ ] 如果改动涉及新功能或行为调整,已补齐或更新 `.trae/specs/`,或说明无需更新的原因。 / If this adds a feature or behavior change, `.trae/specs/` is updated, or the reason for not updating is explained.

View File

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

View File

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

51
.github/workflows/installer-build.yml vendored Normal file
View File

@@ -0,0 +1,51 @@
name: LanDesktopPLONDS Installer Build
on:
push:
tags-ignore:
- '*'
paths:
- '.github/workflows/installer-build.yml'
- 'Directory.Packages.props'
- 'LanDesktopPLONDS.installer/**'
- 'LanMountainDesktop.Shared.Contracts/**'
pull_request:
paths:
- '.github/workflows/installer-build.yml'
- 'Directory.Packages.props'
- 'LanDesktopPLONDS.installer/**'
- 'LanMountainDesktop.Shared.Contracts/**'
workflow_dispatch:
env:
DOTNET_VERSION: '10.0.x'
INSTALLER_PROJECT: LanDesktopPLONDS.installer/LanDesktopPLONDS.installer.csproj
DOTNET_gcServer: 1
jobs:
build-installer:
runs-on: windows-latest
name: Build_Installer_${{ matrix.configuration }}
strategy:
fail-fast: false
matrix:
configuration: [Debug, Release]
steps:
- name: Checkout
uses: actions/checkout@v4
with:
fetch-depth: 0
submodules: recursive
- name: Setup .NET
uses: actions/setup-dotnet@v4
with:
dotnet-version: ${{ env.DOTNET_VERSION }}
dotnet-quality: preview
- name: Restore installer
run: dotnet restore ${{ env.INSTALLER_PROJECT }}
- name: Build installer
run: dotnet build ${{ env.INSTALLER_PROJECT }} --no-restore -c ${{ matrix.configuration }} -v minimal

View File

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

View File

@@ -185,6 +185,29 @@ jobs:
-p:InformationalVersion=${{ needs.prepare.outputs.informational_version }}
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
run: |
$arch = "${{ matrix.arch }}"
@@ -215,6 +238,7 @@ jobs:
$arch = "${{ matrix.arch }}"
$publishDir = "publish/windows-$arch"
$launcherPublishDir = "publish/launcher-win-$arch"
$runtimePublishDir = "publish/airapp-runtime-win-$arch"
$appDir = "app-$version"
$newStructure = "publish-launcher/windows-$arch"
@@ -226,10 +250,15 @@ jobs:
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
Remove-Item -Path $publishDir -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
shell: pwsh
@@ -244,6 +273,28 @@ jobs:
-AssertClean
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
run: |
choco install innosetup -y --no-progress
@@ -309,7 +360,7 @@ jobs:
run: |
$version = "${{ needs.prepare.outputs.version }}"
$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)) {
Write-Error "Payload root not found: $payloadRoot"
exit 1
@@ -323,7 +374,7 @@ jobs:
Get-ChildItem -Path $payloadRoot -Recurse -File | ForEach-Object {
$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
}
@@ -441,12 +492,32 @@ jobs:
-p:FileVersion=${{ needs.prepare.outputs.assembly_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
run: |
version="${{ needs.prepare.outputs.version }}"
publishDir="publish/linux-x64"
appDir="app-$version"
launcherDir="publish/launcher-linux-x64"
runtimeDir="publish/airapp-runtime-linux-x64"
mkdir -p "$publishDir"
mv "publish/linux-x64-app" "$publishDir/$appDir"
@@ -456,8 +527,13 @@ jobs:
chmod +x "$publishDir/LanMountainDesktop.Launcher" 2>/dev/null || true
fi
if [ -d "$runtimeDir" ]; then
cp -r "$runtimeDir"/* "$publishDir/"
chmod +x "$publishDir/LanMountainDesktop.AirAppRuntime" 2>/dev/null || true
fi
touch "$publishDir/$appDir/.current"
rm -rf "$launcherDir"
rm -rf "$launcherDir" "$runtimeDir"
- name: Package as DEB
run: |
@@ -616,10 +692,10 @@ jobs:
dotnet publish LanMountainDesktop/LanMountainDesktop.csproj \
-c Release \
-o ./publish/macos-${{ matrix.arch }}-app \
--self-contained \
--self-contained:false \
-r osx-${{ matrix.arch }} \
-p:SelfContained=false \
-p:PublishSingleFile=false \
-p:SelfContained=true \
-p:DebugType=none \
-p:DebugSymbols=false \
-p:SkipAirAppHostBuild=true \
@@ -630,6 +706,36 @@ jobs:
-p:FileVersion=${{ needs.prepare.outputs.assembly_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
run: |
release_dir="$PWD/release-assets"
@@ -652,6 +758,7 @@ jobs:
app_name="LanMountainDesktop"
package_name="${app_name}-${version}-macos-${arch}"
launcherDir="publish/launcher-macos-$arch"
runtimeDir="publish/airapp-runtime-macos-$arch"
appSourceDir="publish/macos-$arch-app"
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
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"
mkdir -p "${app_name}.app/Contents/Resources"

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

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

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

View File

@@ -164,3 +164,25 @@
* ~~搜索功能~~根据Windows 11小组件面板设计暂不提供搜索功能
## 2026-06 Fusion Desktop Editing Update
### Requirement: Library window controls edit mode
The fused desktop component library is the edit-mode boundary. Opening the independent Fluent-style library window enters fused desktop edit mode. Closing that window exits edit mode. While edit mode is active, component windows can be moved but their inner component UI is not hit-test interactive. After the library closes, component windows cannot be moved and their normal component UI interaction resumes.
### Requirement: Add button keeps the library open
The selected preview component can only be added through the library add button. Adding a component places it at the center of the library window's current screen and keeps the library open so the user can continue adding and placing components. Components must not be dragged out of the library.
### Requirement: Preview swipe changes the selected component
The right-side preview area maintains a selected component index for the current category. Selecting a category chooses the first component in that category. Vertical touch-style swipes in the preview area switch to the previous or next component in the same category with a 48 DIP threshold and wrap at the ends. Mouse wheel and Up/Down keys may provide equivalent desktop input.
### Requirement: Reuse existing desktop grid settings
Fusion desktop placement must reuse the existing Lan Mountain desktop grid settings exposed by the components settings page: short-side cell count, spacing preset, and desktop edge inset. No independent fused-desktop grid configuration source should be introduced. Adding a component and releasing a dragged component both resolve the current grid through the existing grid settings service.
### Requirement: Snap individual windows to the grid
Fusion desktop no longer displays or depends on a full-screen grid window. Each component window uses the grid only as an individual placement constraint. Dragging remains free while the pointer is moving; on release, the window snaps to the nearest cell that can contain its saved cell span, clamps inside the current screen grid, and persists `X`, `Y`, `GridRow`, `GridColumn`, `GridWidthCells`, and `GridHeightCells`.

View File

@@ -1,5 +1,7 @@
# 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.Launcher` builds in Debug.
- [x] `LanMountainDesktop` builds in Debug.

View File

@@ -1,5 +1,7 @@
# 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
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.

View File

@@ -1,5 +1,7 @@
# 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 Launcher Air APP lifecycle service and dedicated IPC host.
- [x] Make Launcher remain alive while desktop or Air APP processes exist.

View File

@@ -3,7 +3,7 @@
- [ ] New install shows OOBE once.
- [ ] Same-user reinstall does not show OOBE again.
- [ ] `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.
- [ ] 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).

View File

@@ -23,12 +23,11 @@ Stabilize the launcher startup path so that:
- `launchSource` values are treated as:
- `normal`
- `postinstall`
- `apply-update`
- `plugin-install`
- `debug-preview`
- 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.
- `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:
- the installer itself
- full installer update application

View File

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

View File

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

View File

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

View File

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

View File

@@ -0,0 +1,549 @@
# PLONDS Comparator 改造设计
> 日期2026-05-30
> 状态:待审批
## 1. 背景与动机
PLONDSPenguin 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. 对比哈希值,生成 filesMapadd/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 命令的清理(后续处理)

View File

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

View File

@@ -15,11 +15,14 @@ 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.
- 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.
- 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.
- The page follows ClassIsland's durable-status vs working-status split: a transient check/download error must not be treated as an available update, and available/downloaded actions must stay visible while the worker is idle.
## Acceptance
- `UpdateSettingsPage` shows Fluent Avalonia controls for channel, mode, thread count, forced reinstall, pause/resume, and cancel.
- `UpdateSettingsState` persists forced reinstall alongside other update preferences.
- Automatic startup checks skip manual mode, download in silent download/silent install modes, and leave installation to explicit user action or exit-time apply.
- After a successful check with an available update, the download action is visible even though no transfer is running.
- After a failed check, no download action is shown unless a valid update is still pending.
- Build succeeds for `LanMountainDesktop.slnx`.

View File

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

File diff suppressed because it is too large Load Diff

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

View File

@@ -3,21 +3,21 @@
<ManagePackageVersionsCentrally>true</ManagePackageVersionsCentrally>
</PropertyGroup>
<ItemGroup>
<PackageVersion Include="Avalonia" Version="12.0.2" />
<PackageVersion Include="Avalonia.Controls.WebView" Version="12.0.0" />
<PackageVersion Include="Avalonia.Desktop" Version="12.0.2" />
<PackageVersion Include="Avalonia.Fonts.Inter" Version="12.0.2" />
<PackageVersion Include="Avalonia.Themes.Fluent" Version="12.0.2" />
<PackageVersion Include="Avalonia" Version="12.0.3" />
<PackageVersion Include="Avalonia.Controls.WebView" Version="12.0.1" />
<PackageVersion Include="Avalonia.Desktop" Version="12.0.3" />
<PackageVersion Include="Avalonia.Fonts.Inter" Version="12.0.3" />
<PackageVersion Include="Avalonia.Themes.Fluent" Version="12.0.3" />
<PackageVersion Include="AvaloniaUI.DiagnosticsSupport" Version="2.2.1" />
<PackageVersion Include="ClassIsland.Markdown.Avalonia" Version="12.0.0" />
<PackageVersion Include="CommunityToolkit.Mvvm" Version="8.4.2" />
<PackageVersion Include="dotnetCampus.Ipc" Version="2.0.0-alpha436" />
<PackageVersion Include="DotNetCampus.AvaloniaInkCanvas" Version="1.0.1" />
<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="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="Material.Icons.Avalonia" Version="3.0.3-nightly.0.2" />
<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.Word" Version="2.0.9" />
<PackageVersion Include="PortAudioSharp2" Version="1.0.6" />
<PackageVersion Include="PostHog" Version="2.6.0" />
<PackageVersion Include="Sentry" Version="6.4.1" />
<PackageVersion Include="PostHog" Version="2.7.1" />
<PackageVersion Include="Sentry" Version="6.5.0" />
<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="Tmds.DBus.Protocol" Version="0.92.0" />

View File

@@ -0,0 +1,202 @@
<Application xmlns="https://github.com/avaloniaui"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
xmlns:sty="using:FluentAvalonia.Styling"
xmlns:fi="using:FluentIcons.Avalonia"
x:Class="LanDesktopPLONDS.Installer.App"
RequestedThemeVariant="Default">
<Application.Resources>
<ResourceDictionary>
<FontFamily x:Key="AppFontFamily">Inter, Segoe UI, Microsoft YaHei UI</FontFamily>
<CornerRadius x:Key="DesignCornerRadiusMicro">2</CornerRadius>
<CornerRadius x:Key="DesignCornerRadiusXs">4</CornerRadius>
<CornerRadius x:Key="DesignCornerRadiusSm">4</CornerRadius>
<CornerRadius x:Key="DesignCornerRadiusMd">8</CornerRadius>
<CornerRadius x:Key="DesignCornerRadiusLg">8</CornerRadius>
<CornerRadius x:Key="DesignCornerRadiusXl">12</CornerRadius>
<CornerRadius x:Key="DesignCornerRadiusComponent">8</CornerRadius>
<ResourceDictionary.ThemeDictionaries>
<ResourceDictionary x:Key="Default">
<SolidColorBrush x:Key="InstallerWindowBackgroundBrush" Color="#F3F3F3" />
<SolidColorBrush x:Key="InstallerPaneBackgroundBrush" Color="#F9F9F9" />
<SolidColorBrush x:Key="InstallerContentBackgroundBrush" Color="#FFFFFF" />
<SolidColorBrush x:Key="InstallerSurfaceBrush" Color="#FFFFFF" />
<SolidColorBrush x:Key="InstallerSurfaceAltBrush" Color="#F7F7F7" />
<SolidColorBrush x:Key="InstallerSubtleFillBrush" Color="#F5F5F5" />
<SolidColorBrush x:Key="InstallerSubtleFillHoverBrush" Color="#EFEFEF" />
<SolidColorBrush x:Key="InstallerSubtleFillPressedBrush" Color="#E5E5E5" />
<SolidColorBrush x:Key="InstallerBorderBrush" Color="#14000000" />
<SolidColorBrush x:Key="InstallerStrongBorderBrush" Color="#29000000" />
<SolidColorBrush x:Key="InstallerTextPrimaryBrush" Color="#1A1A1A" />
<SolidColorBrush x:Key="InstallerTextSecondaryBrush" Color="#5D5D5D" />
<SolidColorBrush x:Key="InstallerTextTertiaryBrush" Color="#6B6B6B" />
<SolidColorBrush x:Key="InstallerDisabledTextBrush" Color="#8A8A8A" />
<SolidColorBrush x:Key="InstallerAccentBrush" Color="#0067C0" />
<SolidColorBrush x:Key="InstallerAccentHoverBrush" Color="#005A9E" />
<SolidColorBrush x:Key="InstallerAccentPressedBrush" Color="#004578" />
<SolidColorBrush x:Key="InstallerOnAccentBrush" Color="#FFFFFF" />
<SolidColorBrush x:Key="InstallerSuccessBrush" Color="#0F7B0F" />
<SolidColorBrush x:Key="InstallerErrorBrush" Color="#B3261E" />
<SolidColorBrush x:Key="InstallerErrorBackgroundBrush" Color="#FFF4F3" />
<SolidColorBrush x:Key="InstallerErrorBorderBrush" Color="#F3B8B3" />
</ResourceDictionary>
<ResourceDictionary x:Key="Dark">
<SolidColorBrush x:Key="InstallerWindowBackgroundBrush" Color="#202020" />
<SolidColorBrush x:Key="InstallerPaneBackgroundBrush" Color="#272727" />
<SolidColorBrush x:Key="InstallerContentBackgroundBrush" Color="#1B1B1B" />
<SolidColorBrush x:Key="InstallerSurfaceBrush" Color="#2B2B2B" />
<SolidColorBrush x:Key="InstallerSurfaceAltBrush" Color="#252525" />
<SolidColorBrush x:Key="InstallerSubtleFillBrush" Color="#333333" />
<SolidColorBrush x:Key="InstallerSubtleFillHoverBrush" Color="#3A3A3A" />
<SolidColorBrush x:Key="InstallerSubtleFillPressedBrush" Color="#444444" />
<SolidColorBrush x:Key="InstallerBorderBrush" Color="#24FFFFFF" />
<SolidColorBrush x:Key="InstallerStrongBorderBrush" Color="#3DFFFFFF" />
<SolidColorBrush x:Key="InstallerTextPrimaryBrush" Color="#F3F3F3" />
<SolidColorBrush x:Key="InstallerTextSecondaryBrush" Color="#C7C7C7" />
<SolidColorBrush x:Key="InstallerTextTertiaryBrush" Color="#A0A0A0" />
<SolidColorBrush x:Key="InstallerDisabledTextBrush" Color="#7A7A7A" />
<SolidColorBrush x:Key="InstallerAccentBrush" Color="#60CDFF" />
<SolidColorBrush x:Key="InstallerAccentHoverBrush" Color="#8AD7FF" />
<SolidColorBrush x:Key="InstallerAccentPressedBrush" Color="#4CC2FF" />
<SolidColorBrush x:Key="InstallerOnAccentBrush" Color="#000000" />
<SolidColorBrush x:Key="InstallerSuccessBrush" Color="#6CCB5F" />
<SolidColorBrush x:Key="InstallerErrorBrush" Color="#FFB4AB" />
<SolidColorBrush x:Key="InstallerErrorBackgroundBrush" Color="#442726" />
<SolidColorBrush x:Key="InstallerErrorBorderBrush" Color="#8C4A45" />
</ResourceDictionary>
</ResourceDictionary.ThemeDictionaries>
</ResourceDictionary>
</Application.Resources>
<Application.Styles>
<sty:FluentAvaloniaTheme />
<Style Selector="Window">
<Setter Property="FontFamily" Value="{DynamicResource AppFontFamily}" />
</Style>
<Style Selector="UserControl">
<Setter Property="FontFamily" Value="{DynamicResource AppFontFamily}" />
</Style>
<Style Selector="fi|FluentIcon">
<Setter Property="Foreground" Value="{DynamicResource InstallerTextPrimaryBrush}" />
<Setter Property="VerticalAlignment" Value="Center" />
<Setter Property="HorizontalAlignment" Value="Center" />
</Style>
<Style Selector="TextBlock">
<Setter Property="Foreground" Value="{DynamicResource InstallerTextPrimaryBrush}" />
<Setter Property="FontWeight" Value="Normal" />
</Style>
<Style Selector="Button.titlebar-icon-button">
<Setter Property="Width" Value="40" />
<Setter Property="Height" Value="40" />
<Setter Property="MinWidth" Value="40" />
<Setter Property="Padding" Value="0" />
<Setter Property="Background" Value="Transparent" />
<Setter Property="BorderThickness" Value="0" />
<Setter Property="CornerRadius" Value="{DynamicResource DesignCornerRadiusSm}" />
</Style>
<Style Selector="Button.titlebar-icon-button:pointerover">
<Setter Property="Background" Value="{DynamicResource InstallerSubtleFillHoverBrush}" />
</Style>
<Style Selector="Button.titlebar-icon-button:pressed">
<Setter Property="Background" Value="{DynamicResource InstallerStrongBorderBrush}" />
</Style>
<Style Selector="StackPanel.installer-page-container">
<Setter Property="Spacing" Value="20" />
<Setter Property="Margin" Value="0" />
<Setter Property="MaxWidth" Value="780" />
</Style>
<Style Selector="TextBlock.page-title-text">
<Setter Property="FontSize" Value="30" />
<Setter Property="FontWeight" Value="SemiBold" />
<Setter Property="LineHeight" Value="38" />
<Setter Property="Foreground" Value="{DynamicResource InstallerTextPrimaryBrush}" />
</Style>
<Style Selector="TextBlock.page-description-text">
<Setter Property="FontSize" Value="14" />
<Setter Property="LineHeight" Value="21" />
<Setter Property="Foreground" Value="{DynamicResource InstallerTextSecondaryBrush}" />
<Setter Property="TextWrapping" Value="Wrap" />
</Style>
<Style Selector="TextBlock.caption-text">
<Setter Property="FontSize" Value="12" />
<Setter Property="LineHeight" Value="17" />
<Setter Property="Foreground" Value="{DynamicResource InstallerTextTertiaryBrush}" />
<Setter Property="TextWrapping" Value="Wrap" />
</Style>
<Style Selector="Button.primary-command">
<Setter Property="Background" Value="{DynamicResource InstallerAccentBrush}" />
<Setter Property="Foreground" Value="{DynamicResource InstallerOnAccentBrush}" />
<Setter Property="BorderThickness" Value="0" />
<Setter Property="CornerRadius" Value="{DynamicResource DesignCornerRadiusSm}" />
<Setter Property="Padding" Value="18,9" />
<Setter Property="MinHeight" Value="38" />
<Setter Property="FontWeight" Value="SemiBold" />
</Style>
<Style Selector="Button.primary-command:pointerover">
<Setter Property="Background" Value="{DynamicResource InstallerAccentHoverBrush}" />
</Style>
<Style Selector="Button.primary-command:pressed">
<Setter Property="Background" Value="{DynamicResource InstallerAccentPressedBrush}" />
</Style>
<Style Selector="Button.primary-command:disabled">
<Setter Property="Background" Value="{DynamicResource InstallerSubtleFillBrush}" />
<Setter Property="Foreground" Value="{DynamicResource InstallerDisabledTextBrush}" />
</Style>
<Style Selector="Button.primary-command fi|FluentIcon">
<Setter Property="Foreground" Value="{DynamicResource InstallerOnAccentBrush}" />
</Style>
<Style Selector="Button.primary-command TextBlock">
<Setter Property="Foreground" Value="{DynamicResource InstallerOnAccentBrush}" />
</Style>
<Style Selector="Button.primary-command:disabled fi|FluentIcon">
<Setter Property="Foreground" Value="{DynamicResource InstallerDisabledTextBrush}" />
</Style>
<Style Selector="Button.primary-command:disabled TextBlock">
<Setter Property="Foreground" Value="{DynamicResource InstallerDisabledTextBrush}" />
</Style>
<Style Selector="Button.secondary-command">
<Setter Property="Background" Value="{DynamicResource InstallerSurfaceBrush}" />
<Setter Property="Foreground" Value="{DynamicResource InstallerTextPrimaryBrush}" />
<Setter Property="BorderBrush" Value="{DynamicResource InstallerStrongBorderBrush}" />
<Setter Property="BorderThickness" Value="1" />
<Setter Property="CornerRadius" Value="{DynamicResource DesignCornerRadiusSm}" />
<Setter Property="Padding" Value="16,9" />
<Setter Property="MinHeight" Value="38" />
</Style>
<Style Selector="Button.secondary-command:pointerover">
<Setter Property="Background" Value="{DynamicResource InstallerSubtleFillHoverBrush}" />
</Style>
<Style Selector="Button.secondary-command:disabled">
<Setter Property="Background" Value="{DynamicResource InstallerSubtleFillBrush}" />
<Setter Property="Foreground" Value="{DynamicResource InstallerDisabledTextBrush}" />
<Setter Property="BorderBrush" Value="{DynamicResource InstallerBorderBrush}" />
</Style>
<Style Selector="Button.secondary-command TextBlock">
<Setter Property="Foreground" Value="{DynamicResource InstallerTextPrimaryBrush}" />
</Style>
<Style Selector="Button.secondary-command fi|FluentIcon">
<Setter Property="Foreground" Value="{DynamicResource InstallerTextPrimaryBrush}" />
</Style>
<Style Selector="Button.secondary-command:disabled TextBlock">
<Setter Property="Foreground" Value="{DynamicResource InstallerDisabledTextBrush}" />
</Style>
<Style Selector="Button.secondary-command:disabled fi|FluentIcon">
<Setter Property="Foreground" Value="{DynamicResource InstallerDisabledTextBrush}" />
</Style>
<Style Selector="TextBox">
<Setter Property="MinHeight" Value="38" />
<Setter Property="Foreground" Value="{DynamicResource InstallerTextPrimaryBrush}" />
<Setter Property="Background" Value="{DynamicResource InstallerSurfaceBrush}" />
<Setter Property="BorderBrush" Value="{DynamicResource InstallerStrongBorderBrush}" />
<Setter Property="CornerRadius" Value="{DynamicResource DesignCornerRadiusSm}" />
</Style>
<Style Selector="CheckBox">
<Setter Property="Foreground" Value="{DynamicResource InstallerTextPrimaryBrush}" />
</Style>
<Style Selector="ProgressBar">
<Setter Property="Foreground" Value="{DynamicResource InstallerAccentBrush}" />
<Setter Property="Background" Value="{DynamicResource InstallerSubtleFillBrush}" />
<Setter Property="MinHeight" Value="6" />
</Style>
</Application.Styles>
</Application>

View File

@@ -0,0 +1,35 @@
using Avalonia;
using Avalonia.Controls.ApplicationLifetimes;
using Avalonia.Markup.Xaml;
using LanDesktopPLONDS.Installer.Services;
using LanDesktopPLONDS.Installer.ViewModels;
using LanDesktopPLONDS.Installer.Views;
using LanMountainDesktop.Shared.Contracts.Privacy;
namespace LanDesktopPLONDS.Installer;
public partial class App : Application
{
public override void Initialize()
{
AvaloniaXamlLoader.Load(this);
}
public override void OnFrameworkInitializationCompleted()
{
if (ApplicationLifetime is IClassicDesktopStyleApplicationLifetime desktop)
{
var privacyIdentity = new PrivacyDeviceIdentityProvider();
var installService = OnlineInstallService.CreateDefault(privacyIdentity);
var consentStore = new InstallerPrivacyConsentStore();
var mainWindow = new MainWindow
{
DataContext = new MainWindowViewModel(installService, privacyIdentity, consentStore)
};
desktop.MainWindow = mainWindow;
mainWindow.Show();
}
base.OnFrameworkInitializationCompleted();
}
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 11 KiB

View File

@@ -0,0 +1,34 @@
<Project>
<PropertyGroup Condition="'$(PublishAot)' == 'true'">
<PublishAot>true</PublishAot>
<PublishTrimmed>true</PublishTrimmed>
<TrimMode>partial</TrimMode>
<SelfContained>true</SelfContained>
<PublishSingleFile>true</PublishSingleFile>
<IncludeNativeLibrariesForSelfExtract>true</IncludeNativeLibrariesForSelfExtract>
<EnableCompressionInSingleFile>true</EnableCompressionInSingleFile>
<OptimizationPreference>Size</OptimizationPreference>
<PublishReadyToRun>false</PublishReadyToRun>
</PropertyGroup>
<PropertyGroup>
<AllowUnsafeBlocks>true</AllowUnsafeBlocks>
<AvaloniaUseCompiledBindingsByDefault>true</AvaloniaUseCompiledBindingsByDefault>
</PropertyGroup>
<ItemGroup Condition="'$(PublishAot)' == 'true'">
<TrimmerRootAssembly Include="Avalonia" />
<TrimmerRootAssembly Include="Avalonia.Desktop" />
<TrimmerRootAssembly Include="FluentAvalonia" />
<TrimmerRootAssembly Include="FluentIcons.Avalonia" />
<TrimmerRootAssembly Include="LanDesktopPLONDS.installer" />
<TrimmerRootAssembly Include="System.Text.Json" />
</ItemGroup>
<PropertyGroup Condition="'$(PublishAot)' == 'true'">
<SuppressTrimAnalysisWarnings>false</SuppressTrimAnalysisWarnings>
<TrimmerSingleWarn>false</TrimmerSingleWarn>
<JsonSerializerIsReflectionEnabledByDefault>true</JsonSerializerIsReflectionEnabledByDefault>
<IsAotCompatible>true</IsAotCompatible>
</PropertyGroup>
</Project>

View File

@@ -0,0 +1,34 @@
<Project Sdk="Microsoft.NET.Sdk" TreatAsLocalProperty="Version;PackageVersion;InformationalVersion;AssemblyVersion;FileVersion">
<Import Project="LanDesktopPLONDS.installer.AOT.props" Condition="Exists('LanDesktopPLONDS.installer.AOT.props')" />
<PropertyGroup>
<OutputType>WinExe</OutputType>
<TargetFramework>net10.0</TargetFramework>
<Nullable>enable</Nullable>
<ImplicitUsings>enable</ImplicitUsings>
<Version>0.0.0-dev</Version>
<PackageVersion>$(Version)</PackageVersion>
<AvaloniaUseCompiledBindingsByDefault>true</AvaloniaUseCompiledBindingsByDefault>
<ApplicationIcon>Assets\logo.ico</ApplicationIcon>
<ApplicationManifest Condition="'$(Configuration)' == 'Debug'">app.Debug.manifest</ApplicationManifest>
<ApplicationManifest Condition="'$(Configuration)' != 'Debug'">app.manifest</ApplicationManifest>
</PropertyGroup>
<ItemGroup>
<ProjectReference Include="..\LanMountainDesktop.Shared.Contracts\LanMountainDesktop.Shared.Contracts.csproj" />
</ItemGroup>
<ItemGroup>
<PackageReference Include="Avalonia" />
<PackageReference Include="Avalonia.Desktop" />
<PackageReference Include="Avalonia.Fonts.Inter" />
<PackageReference Include="FluentAvaloniaUI" />
<PackageReference Include="FluentIcons.Avalonia" />
<PackageReference Include="CommunityToolkit.Mvvm" />
</ItemGroup>
<ItemGroup>
<AvaloniaResource Include="Assets\logo.ico" />
<AvaloniaResource Include="..\LanMountainDesktop\Assets\logo_nightly.png" Link="Assets\logo_nightly.png" />
</ItemGroup>
</Project>

View File

@@ -0,0 +1,10 @@
namespace LanDesktopPLONDS.Installer.Models;
public sealed record InstallerDeployProgress(
string Stage,
string? TargetVersion,
double DownloadProgress,
double InstallProgress,
string? CurrentFile,
long BytesDownloaded,
long? TotalBytes);

View File

@@ -0,0 +1,10 @@
namespace LanDesktopPLONDS.Installer.Models;
public enum InstallerStepId
{
Welcome = 0,
InstallLocation = 1,
PrivacyConfirm = 2,
Deploy = 3,
Complete = 4
}

View File

@@ -0,0 +1,9 @@
namespace LanDesktopPLONDS.Installer.Models;
public sealed record InstallerWorkflowState(
InstallerStepId CurrentStep,
InstallerStepId MaxUnlockedStep,
string InstallPath,
bool PrivacyConfirmed,
string? TargetVersion,
string? ErrorMessage);

View File

@@ -0,0 +1,20 @@
using Avalonia;
namespace LanDesktopPLONDS.Installer;
public static class Program
{
[STAThread]
public static void Main(string[] args)
{
BuildAvaloniaApp().StartWithClassicDesktopLifetime(args);
}
public static AppBuilder BuildAvaloniaApp()
{
return AppBuilder.Configure<App>()
.UsePlatformDetect()
.WithInterFont()
.LogToTrace();
}
}

View File

@@ -0,0 +1,3 @@
using System.Runtime.CompilerServices;
[assembly: InternalsVisibleTo("LanMountainDesktop.Tests")]

View File

@@ -0,0 +1,350 @@
using System.Diagnostics;
using LanDesktopPLONDS.Installer.Models;
namespace LanDesktopPLONDS.Installer.Services;
internal sealed class FilesPackageInstaller
{
public async Task InstallAsync(
PreparedFilesPackage package,
string installPath,
IProgress<InstallerDeployProgress>? progress,
CancellationToken cancellationToken)
{
await InstallAsync(package, installPath, OnlineInstallOptions.Default, progress, cancellationToken)
.ConfigureAwait(false);
}
public async Task InstallAsync(
PreparedFilesPackage package,
string installPath,
OnlineInstallOptions options,
IProgress<InstallerDeployProgress>? progress,
CancellationToken cancellationToken)
{
ArgumentNullException.ThrowIfNull(package);
var launcherRoot = InstallerPathGuard.NormalizeInstallPath(installPath);
var sourceAppDirectory = ResolveFullPackageAppDirectory(package.ExtractDirectory, package.Version);
var targetDeployment = BuildDeploymentDirectory(launcherRoot, package.Version);
InstallerPathGuard.EnsureUsableInstallPath(launcherRoot, EstimateRequiredBytes(sourceAppDirectory));
Directory.CreateDirectory(launcherRoot);
await CopyLauncherRootPayloadAsync(package.ExtractDirectory, sourceAppDirectory, launcherRoot, package.Version, progress, cancellationToken)
.ConfigureAwait(false);
progress?.Report(new InstallerDeployProgress(
"Creating deployment",
package.Version,
1,
0.15,
null,
0,
null));
PrepareTargetDirectory(targetDeployment);
await CopyDirectoryAsync(sourceAppDirectory, targetDeployment, package.Version, progress, cancellationToken)
.ConfigureAwait(false);
progress?.Report(new InstallerDeployProgress(
"Activating deployment",
package.Version,
1,
0.92,
null,
0,
null));
ActivateInitialDeployment(launcherRoot, targetDeployment);
CreateWindowsShortcutsIfAvailable(launcherRoot, options);
progress?.Report(new InstallerDeployProgress(
"Completed",
package.Version,
1,
1,
null,
0,
null));
}
public static string BuildDeploymentDirectory(string launcherRoot, string version)
{
var sanitized = string.IsNullOrWhiteSpace(version) ? "0.0.0" : version.Trim();
var index = 0;
while (true)
{
var candidate = Path.Combine(launcherRoot, $"app-{sanitized}-{index}");
if (!Directory.Exists(candidate))
{
return candidate;
}
index++;
}
}
public static string ResolveFullPackageAppDirectory(string filesDirectory, string version)
{
var root = Path.GetFullPath(filesDirectory);
if (!Directory.Exists(root))
{
throw new DirectoryNotFoundException($"PLONDS Files package directory is missing: {root}");
}
var executableName = OperatingSystem.IsWindows() ? "LanMountainDesktop.exe" : "LanMountainDesktop";
var directExecutable = Path.Combine(root, executableName);
if (File.Exists(directExecutable))
{
return root;
}
var versionDirectory = Directory
.EnumerateDirectories(root, $"app-{version}*", SearchOption.TopDirectoryOnly)
.FirstOrDefault(path => File.Exists(Path.Combine(path, executableName)));
if (!string.IsNullOrWhiteSpace(versionDirectory))
{
return versionDirectory;
}
var nested = Directory
.EnumerateDirectories(root, "*", SearchOption.AllDirectories)
.FirstOrDefault(path => File.Exists(Path.Combine(path, executableName)));
if (!string.IsNullOrWhiteSpace(nested))
{
return nested;
}
throw new FileNotFoundException($"PLONDS Files package does not contain {executableName}.");
}
private static void PrepareTargetDirectory(string targetDeployment)
{
if (Directory.Exists(targetDeployment))
{
Directory.Delete(targetDeployment, recursive: true);
}
Directory.CreateDirectory(targetDeployment);
File.WriteAllText(Path.Combine(targetDeployment, ".partial"), string.Empty);
}
private static async Task CopyDirectoryAsync(
string sourceDirectory,
string targetDirectory,
string version,
IProgress<InstallerDeployProgress>? progress,
CancellationToken cancellationToken)
{
var sourceFiles = Directory.EnumerateFiles(sourceDirectory, "*", SearchOption.AllDirectories).ToArray();
var total = Math.Max(1, sourceFiles.Length);
for (var index = 0; index < sourceFiles.Length; index++)
{
cancellationToken.ThrowIfCancellationRequested();
var sourcePath = sourceFiles[index];
var relativePath = InstallerPathGuard.NormalizeRelativePath(Path.GetRelativePath(sourceDirectory, sourcePath));
if (IsDeploymentMarker(relativePath))
{
continue;
}
var targetPath = Path.GetFullPath(Path.Combine(targetDirectory, relativePath));
InstallerPathGuard.EnsureChildPath(targetDirectory, targetPath);
var targetParent = Path.GetDirectoryName(targetPath);
if (!string.IsNullOrWhiteSpace(targetParent))
{
Directory.CreateDirectory(targetParent);
}
await using (var source = File.OpenRead(sourcePath))
await using (var target = File.Create(targetPath))
{
await source.CopyToAsync(target, cancellationToken).ConfigureAwait(false);
}
progress?.Report(new InstallerDeployProgress(
"Copying files",
version,
1,
0.18 + ((index + 1) * 0.70 / total),
relativePath,
index + 1,
total));
}
}
private static async Task CopyLauncherRootPayloadAsync(
string packageRoot,
string sourceAppDirectory,
string launcherRoot,
string version,
IProgress<InstallerDeployProgress>? progress,
CancellationToken cancellationToken)
{
var resolvedPackageRoot = Path.GetFullPath(packageRoot);
var resolvedAppDirectory = Path.GetFullPath(sourceAppDirectory);
if (string.Equals(
resolvedPackageRoot.TrimEnd(Path.DirectorySeparatorChar, Path.AltDirectorySeparatorChar),
resolvedAppDirectory.TrimEnd(Path.DirectorySeparatorChar, Path.AltDirectorySeparatorChar),
StringComparison.OrdinalIgnoreCase))
{
return;
}
var files = Directory
.EnumerateFiles(resolvedPackageRoot, "*", SearchOption.AllDirectories)
.Where(path => !InstallerPathGuard.IsSameOrChildPath(resolvedAppDirectory, path))
.Where(path =>
{
var relative = InstallerPathGuard.NormalizeRelativePath(Path.GetRelativePath(resolvedPackageRoot, path));
return !relative.StartsWith("app-", StringComparison.OrdinalIgnoreCase);
})
.ToArray();
var total = Math.Max(1, files.Length);
for (var index = 0; index < files.Length; index++)
{
cancellationToken.ThrowIfCancellationRequested();
var sourcePath = files[index];
var relativePath = InstallerPathGuard.NormalizeRelativePath(Path.GetRelativePath(resolvedPackageRoot, sourcePath));
if (IsDeploymentMarker(relativePath))
{
continue;
}
var targetPath = Path.GetFullPath(Path.Combine(launcherRoot, relativePath));
InstallerPathGuard.EnsureChildPath(launcherRoot, targetPath);
var targetParent = Path.GetDirectoryName(targetPath);
if (!string.IsNullOrWhiteSpace(targetParent))
{
Directory.CreateDirectory(targetParent);
}
await using (var source = File.OpenRead(sourcePath))
await using (var target = File.Create(targetPath))
{
await source.CopyToAsync(target, cancellationToken).ConfigureAwait(false);
}
progress?.Report(new InstallerDeployProgress(
"Copying launcher files",
version,
1,
0.10 + ((index + 1) * 0.05 / total),
relativePath,
index + 1,
total));
}
}
private static void ActivateInitialDeployment(string launcherRoot, string targetDeployment)
{
foreach (var existingCurrent in Directory.EnumerateFiles(launcherRoot, ".current", SearchOption.AllDirectories))
{
try
{
File.Delete(existingCurrent);
}
catch
{
}
}
var partialMarker = Path.Combine(targetDeployment, ".partial");
if (File.Exists(partialMarker))
{
File.Delete(partialMarker);
}
File.WriteAllText(Path.Combine(targetDeployment, ".current"), string.Empty);
Directory.CreateDirectory(Path.Combine(launcherRoot, ".Launcher"));
}
private static long EstimateRequiredBytes(string sourceDirectory)
{
return Directory
.EnumerateFiles(sourceDirectory, "*", SearchOption.AllDirectories)
.Sum(path => new FileInfo(path).Length);
}
private static bool IsDeploymentMarker(string relativePath)
{
var name = Path.GetFileName(relativePath);
return name is ".current" or ".partial" or ".destroy";
}
private static void CreateWindowsShortcutsIfAvailable(string launcherRoot, OnlineInstallOptions options)
{
try
{
if (!OperatingSystem.IsWindows())
{
return;
}
var launcherPath = Path.Combine(launcherRoot, "LanMountainDesktop.Launcher.exe");
if (!File.Exists(launcherPath))
{
var deployedLauncher = Directory
.EnumerateFiles(launcherRoot, "LanMountainDesktop.Launcher.exe", SearchOption.AllDirectories)
.FirstOrDefault();
if (!string.IsNullOrWhiteSpace(deployedLauncher))
{
File.Copy(deployedLauncher, launcherPath, overwrite: true);
}
}
if (!File.Exists(launcherPath))
{
return;
}
var startMenu = Environment.GetFolderPath(Environment.SpecialFolder.CommonStartMenu);
if (string.IsNullOrWhiteSpace(startMenu))
{
startMenu = Environment.GetFolderPath(Environment.SpecialFolder.StartMenu);
}
if (string.IsNullOrWhiteSpace(startMenu))
{
return;
}
var programs = Path.Combine(startMenu, "Programs");
Directory.CreateDirectory(programs);
var shortcutPath = Path.Combine(programs, "LanMountainDesktop.url");
WriteUrlShortcut(shortcutPath, launcherPath);
if (options.CreateDesktopShortcut)
{
var desktop = Environment.GetFolderPath(Environment.SpecialFolder.DesktopDirectory);
if (!string.IsNullOrWhiteSpace(desktop))
{
Directory.CreateDirectory(desktop);
WriteUrlShortcut(Path.Combine(desktop, "LanMountainDesktop.url"), launcherPath);
}
}
if (options.CreateStartupShortcut)
{
var startup = Environment.GetFolderPath(Environment.SpecialFolder.Startup);
if (!string.IsNullOrWhiteSpace(startup))
{
Directory.CreateDirectory(startup);
WriteUrlShortcut(Path.Combine(startup, "LanMountainDesktop.url"), launcherPath);
}
}
}
catch
{
// Shortcut creation is best-effort; deployment itself must remain usable without shell integration.
}
}
private static void WriteUrlShortcut(string shortcutPath, string targetPath)
{
File.WriteAllText(
shortcutPath,
$"[InternetShortcut]{Environment.NewLine}URL=file:///{targetPath.Replace('\\', '/')}{Environment.NewLine}");
}
}

View File

@@ -0,0 +1,29 @@
using LanDesktopPLONDS.Installer.Models;
namespace LanDesktopPLONDS.Installer.Services;
public interface IOnlineInstallService
{
Task<OnlineInstallPackageInfo> CheckLatestAsync(CancellationToken cancellationToken);
Task InstallFreshAsync(
string installPath,
IProgress<InstallerDeployProgress>? progress,
CancellationToken cancellationToken);
Task InstallFreshAsync(
string installPath,
OnlineInstallOptions options,
IProgress<InstallerDeployProgress>? progress,
CancellationToken cancellationToken);
Task RepairAsync(
string installPath,
IProgress<InstallerDeployProgress>? progress,
CancellationToken cancellationToken);
Task UpdateIncrementalAsync(
string installPath,
IProgress<InstallerDeployProgress>? progress,
CancellationToken cancellationToken);
}

View File

@@ -0,0 +1,11 @@
using System.Text.Json.Serialization;
namespace LanDesktopPLONDS.Installer.Services;
[JsonSourceGenerationOptions(
PropertyNamingPolicy = JsonKnownNamingPolicy.CamelCase,
PropertyNameCaseInsensitive = true,
ReadCommentHandling = System.Text.Json.JsonCommentHandling.Skip,
AllowTrailingCommas = true)]
[JsonSerializable(typeof(InstallerPlondsManifest))]
internal sealed partial class InstallerJsonContext : JsonSerializerContext;

View File

@@ -0,0 +1,153 @@
namespace LanDesktopPLONDS.Installer.Services;
public static class InstallerPathGuard
{
public const string ApplicationDirectoryName = "LanMountainDesktop";
public static string GetDefaultInstallPath()
{
var programFiles = Environment.GetFolderPath(Environment.SpecialFolder.ProgramFiles);
if (string.IsNullOrWhiteSpace(programFiles))
{
programFiles = Path.Combine(
Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData),
"Programs");
}
return Path.Combine(programFiles, ApplicationDirectoryName);
}
public static string GetInstallPathForSelectedFolder(string selectedFolder)
{
if (string.IsNullOrWhiteSpace(selectedFolder))
{
throw new ArgumentException("Selected folder is required.", nameof(selectedFolder));
}
var fullPath = Path.GetFullPath(selectedFolder.Trim());
var root = Path.GetPathRoot(fullPath);
var trimmedPath = fullPath.TrimEnd(Path.DirectorySeparatorChar, Path.AltDirectorySeparatorChar);
var trimmedRoot = root?.TrimEnd(Path.DirectorySeparatorChar, Path.AltDirectorySeparatorChar);
var basePath = string.Equals(trimmedPath, trimmedRoot, StringComparison.OrdinalIgnoreCase)
? fullPath
: trimmedPath;
var selectedName = Path.GetFileName(trimmedPath);
var installPath = string.Equals(selectedName, ApplicationDirectoryName, StringComparison.OrdinalIgnoreCase)
? trimmedPath
: Path.Combine(basePath, ApplicationDirectoryName);
return NormalizeInstallPath(installPath);
}
public static string NormalizeInstallPath(string path)
{
if (string.IsNullOrWhiteSpace(path))
{
throw new ArgumentException("Installation path is required.", nameof(path));
}
var fullPath = Path.GetFullPath(path.Trim());
ValidateInstallPath(fullPath);
return fullPath;
}
public static void ValidateInstallPath(string path)
{
if (string.IsNullOrWhiteSpace(path))
{
throw new InvalidOperationException("Installation path is required.");
}
var fullPath = Path.GetFullPath(path);
var root = Path.GetPathRoot(fullPath);
if (string.Equals(
fullPath.TrimEnd(Path.DirectorySeparatorChar, Path.AltDirectorySeparatorChar),
root?.TrimEnd(Path.DirectorySeparatorChar, Path.AltDirectorySeparatorChar),
StringComparison.OrdinalIgnoreCase))
{
throw new InvalidOperationException("Choose a folder instead of a drive root.");
}
var blockedNames = new HashSet<string>(StringComparer.OrdinalIgnoreCase)
{
"Windows",
"System32",
"SysWOW64",
"Program Files",
"Program Files (x86)",
"Users"
};
var name = Path.GetFileName(fullPath.TrimEnd(Path.DirectorySeparatorChar, Path.AltDirectorySeparatorChar));
if (blockedNames.Contains(name))
{
throw new InvalidOperationException("Choose a dedicated application folder.");
}
}
public static void EnsureUsableInstallPath(string path, long requiredBytes)
{
var fullPath = NormalizeInstallPath(path);
var directory = Directory.Exists(fullPath)
? new DirectoryInfo(fullPath)
: Directory.CreateDirectory(fullPath);
var testPath = Path.Combine(directory.FullName, $".write-test-{Guid.NewGuid():N}.tmp");
try
{
File.WriteAllText(testPath, string.Empty);
}
finally
{
if (File.Exists(testPath))
{
File.Delete(testPath);
}
}
var drive = new DriveInfo(directory.Root.FullName);
if (drive.AvailableFreeSpace > 0 && drive.AvailableFreeSpace < requiredBytes)
{
throw new InvalidOperationException("The selected drive does not have enough free space.");
}
}
public static void EnsureChildPath(string parent, string child)
{
if (!IsSameOrChildPath(parent, child))
{
throw new InvalidDataException($"Path escapes the expected root: {child}");
}
}
public static bool IsSameOrChildPath(string parent, string child)
{
var resolvedParent = Path.GetFullPath(parent)
.TrimEnd(Path.DirectorySeparatorChar, Path.AltDirectorySeparatorChar);
var resolvedChild = Path.GetFullPath(child);
return string.Equals(
resolvedParent,
resolvedChild.TrimEnd(Path.DirectorySeparatorChar, Path.AltDirectorySeparatorChar),
StringComparison.OrdinalIgnoreCase)
|| resolvedChild.StartsWith(resolvedParent + Path.DirectorySeparatorChar, StringComparison.OrdinalIgnoreCase)
|| resolvedChild.StartsWith(resolvedParent + Path.AltDirectorySeparatorChar, StringComparison.OrdinalIgnoreCase);
}
public static string NormalizeRelativePath(string relativePath)
{
if (string.IsNullOrWhiteSpace(relativePath))
{
throw new InvalidDataException("Package entry path is empty.");
}
var normalized = relativePath
.Replace('\\', Path.DirectorySeparatorChar)
.Replace('/', Path.DirectorySeparatorChar)
.TrimStart(Path.DirectorySeparatorChar, Path.AltDirectorySeparatorChar);
if (Path.IsPathRooted(normalized) || normalized.Split(Path.DirectorySeparatorChar).Contains(".."))
{
throw new InvalidDataException($"Package entry path is invalid: {relativePath}");
}
return normalized;
}
}

View File

@@ -0,0 +1,391 @@
using System.Globalization;
using System.IO.Compression;
using System.Security.Cryptography;
using System.Text.Json;
using LanDesktopPLONDS.Installer.Models;
namespace LanDesktopPLONDS.Installer.Services;
internal sealed class InstallerPlondsClient(HttpClient httpClient, string stagingRoot)
{
private const string S3ManifestUrlEnvironmentVariable = "LANMOUNTAIN_PLONDS_S3_MANIFEST_URL";
private const string GitHubManifestUrlEnvironmentVariable = "LANMOUNTAIN_PLONDS_GITHUB_MANIFEST_URL";
private const string DefaultS3ManifestUrl = "https://cn-nb1.rains3.com/lmdesktop/lanmountain/update/plonds/PLONDS.json";
private const string DefaultGitHubManifestUrl = "https://github.com/wwiinnddyy/LanMountainDesktop/releases/latest/download/PLONDS.json";
private static readonly JsonSerializerOptions JsonOptions = new()
{
PropertyNameCaseInsensitive = true,
ReadCommentHandling = JsonCommentHandling.Skip,
AllowTrailingCommas = true
};
public static IReadOnlyList<InstallerPlondsSource> CreateBuiltInSources()
{
return
[
new("s3", "s3", ResolveManifestUrl(S3ManifestUrlEnvironmentVariable, DefaultS3ManifestUrl), 100),
new("github", "github", ResolveManifestUrl(GitHubManifestUrlEnvironmentVariable, DefaultGitHubManifestUrl), 50)
];
}
public async Task<InstallerPlondsCandidate> FindLatestAsync(CancellationToken cancellationToken)
{
var sources = CreateBuiltInSources().ToList();
var candidates = new List<InstallerPlondsCandidate>();
for (var index = 0; index < sources.Count; index++)
{
cancellationToken.ThrowIfCancellationRequested();
var source = sources[index];
InstallerPlondsManifest? manifest;
try
{
manifest = await GetManifestAsync(source, cancellationToken).ConfigureAwait(false);
}
catch
{
continue;
}
if (manifest is null)
{
continue;
}
AddManifestSources(sources, manifest.Sources);
var filesUrl = InstallerPlondsUrlResolver.ResolveFilesZipUrls(manifest, source).FirstOrDefault();
if (filesUrl is null)
{
continue;
}
candidates.Add(new InstallerPlondsCandidate(source, manifest, filesUrl));
}
return candidates
.Where(candidate => TryParseVersion(candidate.Manifest.CurrentVersion, out _))
.OrderByDescending(candidate => ParseVersion(candidate.Manifest.CurrentVersion))
.ThenByDescending(candidate => candidate.Source.Priority)
.FirstOrDefault()
?? throw new InvalidOperationException("No usable PLONDS full package source was found.");
}
public async Task<PreparedFilesPackage> DownloadAndPrepareFullPackageAsync(
InstallerPlondsCandidate candidate,
IProgress<InstallerDeployProgress>? progress,
CancellationToken cancellationToken)
{
var version = ParseVersion(candidate.Manifest.CurrentVersion).ToString();
var packageRoot = Path.Combine(stagingRoot, SanitizePathSegment(version), SanitizePathSegment(candidate.Source.Id), "full");
var urls = new[] { candidate.FilesZipUrl }
.Concat(InstallerPlondsUrlResolver.ResolveFilesZipUrls(candidate.Manifest, candidate.Source))
.DistinctBy(uri => uri.AbsoluteUri, StringComparer.OrdinalIgnoreCase)
.ToArray();
Exception? lastError = null;
foreach (var filesZipUrl in urls)
{
cancellationToken.ThrowIfCancellationRequested();
if (Directory.Exists(packageRoot))
{
Directory.Delete(packageRoot, recursive: true);
}
Directory.CreateDirectory(packageRoot);
var zipPath = Path.Combine(packageRoot, "Files.zip");
var extractDirectory = Path.Combine(packageRoot, "Files");
Directory.CreateDirectory(extractDirectory);
var attempt = candidate with { FilesZipUrl = filesZipUrl };
try
{
await DownloadToFileAsync(attempt, zipPath, progress, cancellationToken).ConfigureAwait(false);
await VerifyPackageAsync(zipPath, attempt.Manifest, filesZipUrl, cancellationToken).ConfigureAwait(false);
ExtractZip(zipPath, extractDirectory);
progress?.Report(new InstallerDeployProgress(
"Files package prepared",
version,
1,
0.10,
"Files.zip",
new FileInfo(zipPath).Length,
new FileInfo(zipPath).Length));
return new PreparedFilesPackage(version, candidate.Source.Id, zipPath, extractDirectory, candidate.Manifest);
}
catch (OperationCanceledException)
{
throw;
}
catch (Exception ex)
{
lastError = ex;
}
}
throw new InvalidOperationException("Failed to download and prepare the PLONDS Files package.", lastError);
}
public static long EstimateInstallBytes(InstallerPlondsManifest manifest)
{
var filesBytes = manifest.FilesMap?.Values.Sum(file => Math.Max(0, file.Size)) ?? 0;
var packageBytes = FindChecksumSizeHint(manifest.Checksums);
return Math.Max(filesBytes, packageBytes);
}
private async Task<InstallerPlondsManifest?> GetManifestAsync(
InstallerPlondsSource source,
CancellationToken cancellationToken)
{
using var response = await httpClient.GetAsync(source.ManifestUrl, HttpCompletionOption.ResponseHeadersRead, cancellationToken)
.ConfigureAwait(false);
if (!response.IsSuccessStatusCode)
{
return null;
}
await using var stream = await response.Content.ReadAsStreamAsync(cancellationToken).ConfigureAwait(false);
return await JsonSerializer.DeserializeAsync(stream, InstallerJsonContext.Default.InstallerPlondsManifest, cancellationToken)
.ConfigureAwait(false);
}
private async Task DownloadToFileAsync(
InstallerPlondsCandidate candidate,
string destinationPath,
IProgress<InstallerDeployProgress>? progress,
CancellationToken cancellationToken)
{
using var response = await httpClient.GetAsync(candidate.FilesZipUrl, HttpCompletionOption.ResponseHeadersRead, cancellationToken)
.ConfigureAwait(false);
response.EnsureSuccessStatusCode();
var totalBytes = response.Content.Headers.ContentLength;
var partialPath = $"{destinationPath}.partial";
long downloaded = 0;
try
{
await using (var source = await response.Content.ReadAsStreamAsync(cancellationToken).ConfigureAwait(false))
await using (var target = File.Create(partialPath))
{
var buffer = new byte[128 * 1024];
while (true)
{
var read = await source.ReadAsync(buffer, cancellationToken).ConfigureAwait(false);
if (read == 0)
{
break;
}
await target.WriteAsync(buffer.AsMemory(0, read), cancellationToken).ConfigureAwait(false);
downloaded += read;
var fraction = totalBytes is > 0 ? Math.Clamp((double)downloaded / totalBytes.Value, 0, 1) : 0;
progress?.Report(new InstallerDeployProgress(
"Downloading Files.zip",
candidate.Manifest.CurrentVersion,
fraction,
0,
"Files.zip",
downloaded,
totalBytes));
}
}
File.Move(partialPath, destinationPath, overwrite: true);
}
finally
{
if (File.Exists(partialPath))
{
File.Delete(partialPath);
}
}
}
private static async Task VerifyPackageAsync(
string zipPath,
InstallerPlondsManifest manifest,
Uri filesZipUrl,
CancellationToken cancellationToken)
{
var checksum = FindChecksum(manifest.Checksums, GetChecksumKeys(filesZipUrl));
if (checksum is null)
{
throw new InvalidDataException("PLONDS manifest does not declare a checksum for Files.zip.");
}
var (algorithm, expectedHash) = ParseChecksum(checksum);
var actualHash = await ComputeHashAsync(zipPath, algorithm, cancellationToken).ConfigureAwait(false);
if (!string.Equals(actualHash, expectedHash, StringComparison.OrdinalIgnoreCase))
{
throw new InvalidDataException(
$"PLONDS Files.zip checksum mismatch. Expected {algorithm}:{expectedHash}, actual {algorithm}:{actualHash}.");
}
}
private static void ExtractZip(string zipPath, string destinationDirectory)
{
if (Directory.Exists(destinationDirectory))
{
Directory.Delete(destinationDirectory, recursive: true);
}
Directory.CreateDirectory(destinationDirectory);
using var archive = ZipFile.OpenRead(zipPath);
foreach (var entry in archive.Entries)
{
var normalizedName = InstallerPathGuard.NormalizeRelativePath(entry.FullName);
var destinationPath = Path.GetFullPath(Path.Combine(destinationDirectory, normalizedName));
InstallerPathGuard.EnsureChildPath(destinationDirectory, destinationPath);
if (string.IsNullOrEmpty(entry.Name))
{
Directory.CreateDirectory(destinationPath);
continue;
}
var parent = Path.GetDirectoryName(destinationPath);
if (!string.IsNullOrWhiteSpace(parent))
{
Directory.CreateDirectory(parent);
}
entry.ExtractToFile(destinationPath, overwrite: true);
}
}
private static void AddManifestSources(List<InstallerPlondsSource> sources, IEnumerable<InstallerPlondsSource>? manifestSources)
{
if (manifestSources is null)
{
return;
}
foreach (var source in manifestSources)
{
if (string.IsNullOrWhiteSpace(source.Id) || string.IsNullOrWhiteSpace(source.ManifestUrl))
{
continue;
}
if (sources.Any(existing => string.Equals(existing.Id, source.Id, StringComparison.OrdinalIgnoreCase) ||
string.Equals(existing.ManifestUrl, source.ManifestUrl, StringComparison.OrdinalIgnoreCase)))
{
continue;
}
sources.Add(source with
{
Id = source.Id.Trim(),
Kind = string.IsNullOrWhiteSpace(source.Kind) ? "http" : source.Kind.Trim(),
ManifestUrl = source.ManifestUrl.Trim()
});
}
}
private static IReadOnlyList<string> GetChecksumKeys(Uri url)
{
var urlFileName = Path.GetFileName(url.LocalPath);
return new[] { "Files.zip", "files.zip", "files-windows-x64.zip", urlFileName }
.Where(key => !string.IsNullOrWhiteSpace(key))
.Distinct(StringComparer.OrdinalIgnoreCase)
.ToArray();
}
private static string? FindChecksum(IReadOnlyDictionary<string, string>? checksums, IEnumerable<string> keys)
{
if (checksums is null || checksums.Count == 0)
{
return null;
}
foreach (var key in keys)
{
if (checksums.TryGetValue(key, out var value) && !string.IsNullOrWhiteSpace(value))
{
return value;
}
var match = checksums.FirstOrDefault(item => string.Equals(item.Key, key, StringComparison.OrdinalIgnoreCase));
if (!string.IsNullOrWhiteSpace(match.Value))
{
return match.Value;
}
}
return null;
}
private static (string Algorithm, string Hash) ParseChecksum(string checksum)
{
var normalized = checksum.Trim();
var separatorIndex = normalized.IndexOf(':', StringComparison.Ordinal);
if (separatorIndex > 0)
{
var algorithm = normalized[..separatorIndex].Trim().ToLowerInvariant();
var hash = NormalizeHash(normalized[(separatorIndex + 1)..]);
if (algorithm is "md5" or "sha256" && hash.Length > 0)
{
return (algorithm, hash);
}
}
var inferred = NormalizeHash(normalized);
return inferred.Length switch
{
32 => ("md5", inferred),
64 => ("sha256", inferred),
_ => throw new InvalidDataException($"Unsupported PLONDS checksum format: {checksum}")
};
}
private static async Task<string> ComputeHashAsync(string filePath, string algorithm, CancellationToken cancellationToken)
{
using HashAlgorithm hasher = algorithm switch
{
"md5" => MD5.Create(),
"sha256" => SHA256.Create(),
_ => throw new InvalidDataException($"Unsupported PLONDS checksum algorithm: {algorithm}")
};
await using var stream = File.OpenRead(filePath);
var hash = await hasher.ComputeHashAsync(stream, cancellationToken).ConfigureAwait(false);
return Convert.ToHexString(hash).ToLowerInvariant();
}
private static long FindChecksumSizeHint(IReadOnlyDictionary<string, string>? checksums)
{
_ = checksums;
return 0;
}
private static Version ParseVersion(string version)
{
var normalized = version.Trim().TrimStart('v', 'V');
return Version.Parse(normalized);
}
private static bool TryParseVersion(string version, out Version parsed)
{
return Version.TryParse(version.Trim().TrimStart('v', 'V'), out parsed!);
}
private static string NormalizeHash(string value)
{
return value.Trim().Replace(" ", string.Empty, StringComparison.Ordinal).ToLowerInvariant();
}
private static string ResolveManifestUrl(string environmentVariable, string fallback)
{
var value = Environment.GetEnvironmentVariable(environmentVariable);
return string.IsNullOrWhiteSpace(value) ? fallback : value.Trim();
}
private static string SanitizePathSegment(string value)
{
var invalid = Path.GetInvalidFileNameChars();
var chars = value.Select(ch => invalid.Contains(ch) ? '_' : ch).ToArray();
var sanitized = new string(chars).Trim();
return string.IsNullOrWhiteSpace(sanitized) ? "unknown" : sanitized;
}
}

View File

@@ -0,0 +1,51 @@
namespace LanDesktopPLONDS.Installer.Services;
internal static class InstallerPlondsUrlResolver
{
public static IReadOnlyList<Uri> ResolveFilesZipUrls(
InstallerPlondsManifest manifest,
InstallerPlondsSource source)
{
var urls = new List<string?>();
var sourceKind = source.Kind.Trim().ToLowerInvariant();
if (sourceKind is "s3")
{
urls.Add(manifest.Downloads?.S3?.FilesZipUrl);
}
else if (sourceKind is "github")
{
urls.Add(manifest.Downloads?.GitHub?.FilesZipUrl);
}
urls.Add(DerivePackageUrl(source.ManifestUrl));
urls.Add(manifest.Downloads?.S3?.FilesZipUrl);
urls.Add(manifest.Downloads?.GitHub?.FilesZipUrl);
return urls
.Where(url => !string.IsNullOrWhiteSpace(url))
.Select(url => Uri.TryCreate(url, UriKind.Absolute, out var uri) ? uri : null)
.OfType<Uri>()
.Where(uri => uri.Scheme is "http" or "https")
.DistinctBy(uri => uri.AbsoluteUri, StringComparer.OrdinalIgnoreCase)
.ToArray();
}
private static string? DerivePackageUrl(string manifestUrl)
{
if (!Uri.TryCreate(manifestUrl, UriKind.Absolute, out var uri) ||
uri.Scheme is not ("http" or "https"))
{
return null;
}
var builder = new UriBuilder(uri);
var lastSlash = builder.Path.LastIndexOf('/');
builder.Path = lastSlash >= 0
? $"{builder.Path[..(lastSlash + 1)]}Files.zip"
: "Files.zip";
builder.Query = string.Empty;
builder.Fragment = string.Empty;
return builder.Uri.AbsoluteUri;
}
}

View File

@@ -0,0 +1,118 @@
using System.Text.Json;
using System.Text.Json.Serialization;
namespace LanDesktopPLONDS.Installer.Services;
public sealed partial class InstallerPrivacyConsentStore
{
private const string ConsentFileName = "privacy-consent.json";
private readonly string _consentPath;
private readonly object _gate = new();
public InstallerPrivacyConsentStore(string? consentPath = null)
{
_consentPath = string.IsNullOrWhiteSpace(consentPath)
? GetDefaultConsentPath()
: Path.GetFullPath(consentPath);
}
public bool HasConfirmed(string deviceId)
{
if (string.IsNullOrWhiteSpace(deviceId))
{
return false;
}
lock (_gate)
{
var document = TryLoad();
return document is not null
&& string.Equals(document.DeviceId, deviceId, StringComparison.OrdinalIgnoreCase)
&& document.ConfirmedAtUtc <= DateTimeOffset.UtcNow;
}
}
public void SaveConfirmed(string deviceId)
{
if (string.IsNullOrWhiteSpace(deviceId))
{
throw new ArgumentException("Device ID is required.", nameof(deviceId));
}
lock (_gate)
{
Save(new InstallerPrivacyConsentDocument(
SchemaVersion: 1,
DeviceId: deviceId,
ConfirmedAtUtc: DateTimeOffset.UtcNow,
Categories:
[
"anonymousDeviceId",
"systemAndArchitecture",
"targetVersion",
"serverReceivedIpAddress"
]));
}
}
public static string GetDefaultConsentPath()
{
var root = Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData);
if (string.IsNullOrWhiteSpace(root))
{
root = AppContext.BaseDirectory;
}
return Path.Combine(root, "LanMountainDesktop", "Installer", ConsentFileName);
}
private InstallerPrivacyConsentDocument? TryLoad()
{
try
{
if (!File.Exists(_consentPath))
{
return null;
}
var json = File.ReadAllText(_consentPath);
return JsonSerializer.Deserialize(
json,
InstallerPrivacyConsentJsonContext.Default.InstallerPrivacyConsentDocument);
}
catch
{
return null;
}
}
private void Save(InstallerPrivacyConsentDocument document)
{
var directory = Path.GetDirectoryName(_consentPath);
if (!string.IsNullOrWhiteSpace(directory))
{
Directory.CreateDirectory(directory);
}
var tempPath = $"{_consentPath}.{Guid.NewGuid():N}.tmp";
var json = JsonSerializer.Serialize(
document,
InstallerPrivacyConsentJsonContext.Default.InstallerPrivacyConsentDocument);
File.WriteAllText(tempPath, json);
File.Move(tempPath, _consentPath, overwrite: true);
}
private sealed record InstallerPrivacyConsentDocument(
int SchemaVersion,
string DeviceId,
DateTimeOffset ConfirmedAtUtc,
IReadOnlyList<string> Categories);
[JsonSourceGenerationOptions(
PropertyNamingPolicy = JsonKnownNamingPolicy.CamelCase,
WriteIndented = true,
DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull)]
[JsonSerializable(typeof(InstallerPrivacyConsentDocument))]
private sealed partial class InstallerPrivacyConsentJsonContext : JsonSerializerContext;
}

View File

@@ -0,0 +1,83 @@
using LanDesktopPLONDS.Installer.Models;
using LanMountainDesktop.Shared.Contracts.Privacy;
namespace LanDesktopPLONDS.Installer.Services;
internal sealed class OnlineInstallService(
InstallerPlondsClient plondsClient,
FilesPackageInstaller packageInstaller,
IPrivacyDeviceIdentityProvider privacyIdentity) : IOnlineInstallService
{
private InstallerPlondsCandidate? _latestCandidate;
public static OnlineInstallService CreateDefault(IPrivacyDeviceIdentityProvider privacyIdentity)
{
var httpClient = new HttpClient
{
Timeout = TimeSpan.FromMinutes(20)
};
var stagingRoot = Path.Combine(
Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData),
"LanMountainDesktop",
"Installer",
"PLONDS");
return new OnlineInstallService(
new InstallerPlondsClient(httpClient, stagingRoot),
new FilesPackageInstaller(),
privacyIdentity);
}
public async Task<OnlineInstallPackageInfo> CheckLatestAsync(CancellationToken cancellationToken)
{
var candidate = await plondsClient.FindLatestAsync(cancellationToken).ConfigureAwait(false);
_latestCandidate = candidate;
return new OnlineInstallPackageInfo(
candidate.Manifest.CurrentVersion,
candidate.Source.Id,
candidate.FilesZipUrl,
InstallerPlondsClient.EstimateInstallBytes(candidate.Manifest));
}
public async Task InstallFreshAsync(
string installPath,
IProgress<InstallerDeployProgress>? progress,
CancellationToken cancellationToken)
{
await InstallFreshAsync(installPath, OnlineInstallOptions.Default, progress, cancellationToken)
.ConfigureAwait(false);
}
public async Task InstallFreshAsync(
string installPath,
OnlineInstallOptions options,
IProgress<InstallerDeployProgress>? progress,
CancellationToken cancellationToken)
{
_ = privacyIdentity.GetOrCreateDeviceId();
var candidate = _latestCandidate ?? await plondsClient.FindLatestAsync(cancellationToken).ConfigureAwait(false);
var package = await plondsClient.DownloadAndPrepareFullPackageAsync(candidate, progress, cancellationToken).ConfigureAwait(false);
await packageInstaller.InstallAsync(package, installPath, options, progress, cancellationToken).ConfigureAwait(false);
}
public Task RepairAsync(
string installPath,
IProgress<InstallerDeployProgress>? progress,
CancellationToken cancellationToken)
{
_ = installPath;
_ = progress;
_ = cancellationToken;
throw new NotSupportedException("Repair is reserved for a later installer version.");
}
public Task UpdateIncrementalAsync(
string installPath,
IProgress<InstallerDeployProgress>? progress,
CancellationToken cancellationToken)
{
_ = installPath;
_ = progress;
_ = cancellationToken;
throw new NotSupportedException("Incremental update is reserved for a later installer version.");
}
}

View File

@@ -0,0 +1,83 @@
namespace LanDesktopPLONDS.Installer.Services;
internal sealed record InstallerPlondsSource(
string Id,
string Kind,
string ManifestUrl,
int Priority = 0);
internal sealed record InstallerPlondsManifest(
string FormatVersion,
string CurrentVersion,
string PreviousVersion,
bool IsFullUpdate,
bool RequiresCleanInstall,
string Channel,
string Platform,
DateTimeOffset UpdatedAt,
IReadOnlyDictionary<string, InstallerPlondsFileEntry> FilesMap,
IReadOnlyDictionary<string, InstallerPlondsChangedFileEntry> ChangedFilesMap,
IReadOnlyDictionary<string, string> Checksums,
InstallerPlondsDownloads? Downloads,
IReadOnlyList<InstallerPlondsSource>? Sources);
internal sealed record InstallerPlondsFileEntry(
string Action,
string Hash,
long Size,
string HashAlgorithm = "sha256");
internal sealed record InstallerPlondsChangedFileEntry(
string ArchivePath,
string Hash,
long Size,
string HashAlgorithm = "sha256");
internal sealed record InstallerPlondsDownloads(
InstallerPlondsGitHubDownloads? GitHub,
InstallerPlondsS3Downloads? S3);
internal sealed record InstallerPlondsGitHubDownloads(
string? ReleaseUrl,
string? ManifestUrl,
string? ChangedZipUrl,
string? FilesZipUrl);
internal sealed record InstallerPlondsS3Downloads(
string? Bucket,
string? Prefix,
string? ManifestKey,
string? ManifestUrl,
string? ChangedZipKey,
string? ChangedZipUrl,
string? ChangedFolderKey,
string? ChangedFolderUrl,
string? FilesZipKey,
string? FilesZipUrl,
string? FilesFolderKey,
string? FilesFolderUrl);
public sealed record OnlineInstallPackageInfo(
string Version,
string SourceId,
Uri FilesZipUrl,
long EstimatedBytes);
public sealed record OnlineInstallOptions(bool CreateDesktopShortcut, bool CreateStartupShortcut)
{
public static OnlineInstallOptions Default { get; } = new(
CreateDesktopShortcut: false,
CreateStartupShortcut: false);
}
internal sealed record InstallerPlondsCandidate(
InstallerPlondsSource Source,
InstallerPlondsManifest Manifest,
Uri FilesZipUrl);
internal sealed record PreparedFilesPackage(
string Version,
string SourceId,
string ZipPath,
string ExtractDirectory,
InstallerPlondsManifest Manifest);

View File

@@ -0,0 +1,23 @@
using CommunityToolkit.Mvvm.ComponentModel;
using FluentIcons.Common;
using LanDesktopPLONDS.Installer.Models;
namespace LanDesktopPLONDS.Installer.ViewModels;
public sealed partial class InstallerStepViewModel(
InstallerStepId stepId,
string title,
Icon icon) : ObservableObject
{
[ObservableProperty]
private bool _isUnlocked;
[ObservableProperty]
private bool _isSelected;
public InstallerStepId StepId { get; } = stepId;
public string Title { get; } = title;
public Icon Icon { get; } = icon;
}

View File

@@ -0,0 +1,372 @@
using System.Collections.ObjectModel;
using System.Diagnostics;
using CommunityToolkit.Mvvm.ComponentModel;
using CommunityToolkit.Mvvm.Input;
using FluentIcons.Common;
using LanDesktopPLONDS.Installer.Models;
using LanDesktopPLONDS.Installer.Services;
using LanMountainDesktop.Shared.Contracts.Privacy;
namespace LanDesktopPLONDS.Installer.ViewModels;
public sealed partial class MainWindowViewModel : ObservableObject
{
private readonly IOnlineInstallService _installService;
private readonly IPrivacyDeviceIdentityProvider _privacyIdentity;
private readonly InstallerPrivacyConsentStore _privacyConsentStore;
private CancellationTokenSource? _installCts;
[ObservableProperty]
[NotifyCanExecuteChangedFor(nameof(NextCommand))]
[NotifyCanExecuteChangedFor(nameof(BackCommand))]
[NotifyCanExecuteChangedFor(nameof(StartInstallCommand))]
private InstallerStepId _currentStep = InstallerStepId.Welcome;
[ObservableProperty]
private InstallerStepId _maxUnlockedStep = InstallerStepId.Welcome;
[ObservableProperty]
[NotifyCanExecuteChangedFor(nameof(NextCommand))]
[NotifyCanExecuteChangedFor(nameof(StartInstallCommand))]
private string _installPath = InstallerPathGuard.GetDefaultInstallPath();
[ObservableProperty]
[NotifyCanExecuteChangedFor(nameof(NextCommand))]
[NotifyCanExecuteChangedFor(nameof(StartInstallCommand))]
private bool _privacyConfirmed;
[ObservableProperty]
private string? _targetVersion;
[ObservableProperty]
private string? _sourceId;
[ObservableProperty]
private string? _errorMessage;
[ObservableProperty]
private string _statusText = "准备开始安装";
[ObservableProperty]
private double _downloadProgress;
[ObservableProperty]
private double _installProgress;
[ObservableProperty]
private string? _currentFile;
[ObservableProperty]
private string _downloadBytesText = string.Empty;
[ObservableProperty]
[NotifyCanExecuteChangedFor(nameof(StartInstallCommand))]
[NotifyCanExecuteChangedFor(nameof(BackCommand))]
[NotifyCanExecuteChangedFor(nameof(NextCommand))]
private bool _isInstalling;
[ObservableProperty]
private bool _createDesktopShortcut;
[ObservableProperty]
private bool _createStartupShortcut;
public MainWindowViewModel(
IOnlineInstallService installService,
IPrivacyDeviceIdentityProvider privacyIdentity,
InstallerPrivacyConsentStore? privacyConsentStore = null)
{
_installService = installService;
_privacyIdentity = privacyIdentity;
_privacyConsentStore = privacyConsentStore ?? new InstallerPrivacyConsentStore();
Steps =
[
new InstallerStepViewModel(InstallerStepId.Welcome, "开始安装", Icon.Play),
new InstallerStepViewModel(InstallerStepId.InstallLocation, "安装位置", Icon.Folder),
new InstallerStepViewModel(InstallerStepId.PrivacyConfirm, "数据确认", Icon.Info),
new InstallerStepViewModel(InstallerStepId.Deploy, "开始部署", Icon.ArrowDownload),
new InstallerStepViewModel(InstallerStepId.Complete, "完成安装", Icon.Circle)
];
SyncSteps();
DeviceIdPreview = _privacyIdentity.GetOrCreateDeviceId();
PrivacyConfirmed = _privacyConsentStore.HasConfirmed(DeviceIdPreview);
}
public ObservableCollection<InstallerStepViewModel> Steps { get; }
public Func<string, Task<string?>>? BrowseRequested { get; set; }
public string WindowTitle => "LanDesktopPLONDS Installer";
public string DeviceIdPreview { get; }
public bool IsWelcomeStep => CurrentStep == InstallerStepId.Welcome;
public bool IsLocationStep => CurrentStep == InstallerStepId.InstallLocation;
public bool IsPrivacyStep => CurrentStep == InstallerStepId.PrivacyConfirm;
public bool IsDeployStep => CurrentStep == InstallerStepId.Deploy;
public bool IsCompleteStep => CurrentStep == InstallerStepId.Complete;
public bool HasError => !string.IsNullOrWhiteSpace(ErrorMessage);
public bool CanGoBack => CurrentStep > InstallerStepId.Welcome && !IsInstalling;
public bool CanGoNext => CurrentStep switch
{
InstallerStepId.Welcome => !IsInstalling,
InstallerStepId.InstallLocation => !string.IsNullOrWhiteSpace(InstallPath) && !IsInstalling,
InstallerStepId.PrivacyConfirm => PrivacyConfirmed && !IsInstalling,
_ => false
};
public bool CanStartInstall => CurrentStep == InstallerStepId.Deploy &&
PrivacyConfirmed &&
!string.IsNullOrWhiteSpace(InstallPath) &&
!IsInstalling;
public InstallerWorkflowState Snapshot => new(
CurrentStep,
MaxUnlockedStep,
InstallPath,
PrivacyConfirmed,
TargetVersion,
ErrorMessage);
partial void OnCurrentStepChanged(InstallerStepId value)
{
OnPropertyChanged(nameof(IsWelcomeStep));
OnPropertyChanged(nameof(IsLocationStep));
OnPropertyChanged(nameof(IsPrivacyStep));
OnPropertyChanged(nameof(IsDeployStep));
OnPropertyChanged(nameof(IsCompleteStep));
OnPropertyChanged(nameof(CanGoBack));
OnPropertyChanged(nameof(CanGoNext));
OnPropertyChanged(nameof(CanStartInstall));
SyncSteps();
}
partial void OnErrorMessageChanged(string? value)
{
_ = value;
OnPropertyChanged(nameof(HasError));
}
partial void OnMaxUnlockedStepChanged(InstallerStepId value)
{
_ = value;
SyncSteps();
}
partial void OnIsInstallingChanged(bool value)
{
_ = value;
OnPropertyChanged(nameof(CanGoBack));
OnPropertyChanged(nameof(CanGoNext));
OnPropertyChanged(nameof(CanStartInstall));
}
[RelayCommand(CanExecute = nameof(CanGoNext))]
private async Task NextAsync()
{
ErrorMessage = null;
if (CurrentStep == InstallerStepId.InstallLocation)
{
try
{
InstallerPathGuard.ValidateInstallPath(InstallPath);
var info = await _installService.CheckLatestAsync(CancellationToken.None);
TargetVersion = info.Version;
SourceId = info.SourceId;
StatusText = $"准备安装 {info.Version}";
}
catch (Exception ex)
{
ErrorMessage = ex.Message;
return;
}
}
else if (CurrentStep == InstallerStepId.PrivacyConfirm)
{
_privacyConsentStore.SaveConfirmed(DeviceIdPreview);
}
UnlockAndNavigate(CurrentStep + 1);
}
[RelayCommand(CanExecute = nameof(CanGoBack))]
private void Back()
{
if (IsInstalling)
{
return;
}
if (CurrentStep > InstallerStepId.Welcome)
{
CurrentStep -= 1;
}
}
[RelayCommand]
private void SelectStep(InstallerStepViewModel? step)
{
if (step is null || IsInstalling || step.StepId > MaxUnlockedStep)
{
return;
}
CurrentStep = step.StepId;
}
[RelayCommand]
private async Task BrowseAsync()
{
ErrorMessage = null;
if (BrowseRequested is null)
{
return;
}
try
{
var selected = await BrowseRequested(InstallPath);
if (!string.IsNullOrWhiteSpace(selected))
{
InstallPath = InstallerPathGuard.GetInstallPathForSelectedFolder(selected);
}
}
catch (Exception ex)
{
ErrorMessage = $"选择安装位置失败:{ex.Message}";
}
}
[RelayCommand(CanExecute = nameof(CanStartInstall))]
private async Task StartInstallAsync()
{
ErrorMessage = null;
IsInstalling = true;
StartInstallCommand.NotifyCanExecuteChanged();
_installCts?.Dispose();
_installCts = new CancellationTokenSource();
try
{
var progress = new Progress<InstallerDeployProgress>(ApplyProgress);
var options = new OnlineInstallOptions(CreateDesktopShortcut, CreateStartupShortcut);
await _installService.InstallFreshAsync(InstallPath, options, progress, _installCts.Token);
UnlockAndNavigate(InstallerStepId.Complete);
StatusText = "安装完成";
}
catch (OperationCanceledException)
{
ErrorMessage = "安装已取消。";
StatusText = "安装已取消";
}
catch (Exception ex)
{
ErrorMessage = ex.Message;
StatusText = "安装失败";
}
finally
{
IsInstalling = false;
StartInstallCommand.NotifyCanExecuteChanged();
}
}
[RelayCommand]
private void CancelInstall()
{
_installCts?.Cancel();
}
[RelayCommand]
private void Launch()
{
LaunchCore();
}
private void LaunchCore()
{
var launcher = Path.Combine(InstallPath, OperatingSystem.IsWindows()
? "LanMountainDesktop.Launcher.exe"
: "LanMountainDesktop.Launcher");
if (!File.Exists(launcher))
{
ErrorMessage = "未找到 LanMountainDesktop.Launcher。";
return;
}
try
{
Process.Start(new ProcessStartInfo
{
FileName = launcher,
Arguments = "--launch-source postinstall",
WorkingDirectory = InstallPath,
UseShellExecute = true
});
}
catch (Exception ex)
{
ErrorMessage = ex.Message;
}
}
private void UnlockAndNavigate(InstallerStepId step)
{
if (step > MaxUnlockedStep)
{
MaxUnlockedStep = step;
}
CurrentStep = step;
}
private void ApplyProgress(InstallerDeployProgress progress)
{
StatusText = progress.Stage;
TargetVersion = progress.TargetVersion ?? TargetVersion;
DownloadProgress = progress.DownloadProgress;
InstallProgress = progress.InstallProgress;
CurrentFile = progress.CurrentFile;
DownloadBytesText = FormatBytes(progress.BytesDownloaded, progress.TotalBytes);
}
private void SyncSteps()
{
foreach (var step in Steps)
{
step.IsUnlocked = step.StepId <= MaxUnlockedStep;
step.IsSelected = step.StepId == CurrentStep;
}
}
private static string FormatBytes(long downloaded, long? total)
{
if (downloaded <= 0 && total is not > 0)
{
return string.Empty;
}
var downloadedText = ToSize(downloaded);
return total is > 0 ? $"{downloadedText} / {ToSize(total.Value)}" : downloadedText;
}
private static string ToSize(long value)
{
string[] suffixes = ["B", "KB", "MB", "GB"];
var size = (double)value;
var suffix = 0;
while (size >= 1024 && suffix < suffixes.Length - 1)
{
size /= 1024;
suffix++;
}
return $"{size:0.##} {suffixes[suffix]}";
}
}

View File

@@ -0,0 +1,536 @@
<Window xmlns="https://github.com/avaloniaui"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
xmlns:fi="using:FluentIcons.Avalonia"
xmlns:vm="using:LanDesktopPLONDS.Installer.ViewModels"
x:Class="LanDesktopPLONDS.Installer.Views.MainWindow"
x:DataType="vm:MainWindowViewModel"
Width="1040"
Height="680"
MinWidth="900"
MinHeight="620"
CanResize="True"
x:Name="Root"
Title="{Binding WindowTitle}"
Background="Transparent"
TransparencyLevelHint="Mica, AcrylicBlur, None"
ExtendClientAreaToDecorationsHint="True"
ExtendClientAreaTitleBarHeightHint="48"
WindowDecorations="None">
<Window.Styles>
<Style Selector="Grid.step-page">
<Setter Property="IsVisible" Value="False" />
</Style>
<Style Selector="TextBlock.muted">
<Setter Property="Foreground" Value="{DynamicResource InstallerTextSecondaryBrush}" />
<Setter Property="TextWrapping" Value="Wrap" />
<Setter Property="LineHeight" Value="20" />
</Style>
<Style Selector="Button.step-nav-item">
<Setter Property="Template">
<ControlTemplate>
<Border Background="{TemplateBinding Background}"
BorderBrush="{TemplateBinding BorderBrush}"
BorderThickness="{TemplateBinding BorderThickness}"
CornerRadius="{TemplateBinding CornerRadius}">
<ContentPresenter Content="{TemplateBinding Content}"
ContentTemplate="{TemplateBinding ContentTemplate}"
HorizontalContentAlignment="{TemplateBinding HorizontalContentAlignment}"
VerticalContentAlignment="{TemplateBinding VerticalContentAlignment}" />
</Border>
</ControlTemplate>
</Setter>
<Setter Property="Background" Value="Transparent" />
<Setter Property="BorderThickness" Value="0" />
<Setter Property="CornerRadius" Value="{DynamicResource DesignCornerRadiusMd}" />
<Setter Property="HorizontalAlignment" Value="Stretch" />
<Setter Property="HorizontalContentAlignment" Value="Stretch" />
<Setter Property="VerticalContentAlignment" Value="Stretch" />
<Setter Property="Margin" Value="0,0,0,3" />
<Setter Property="Padding" Value="0" />
<Setter Property="MinHeight" Value="40" />
</Style>
<Style Selector="Button.step-nav-item:pointerover">
<Setter Property="Background" Value="{DynamicResource InstallerSubtleFillHoverBrush}" />
</Style>
<Style Selector="Button.step-nav-item:pressed">
<Setter Property="Background" Value="{DynamicResource InstallerSubtleFillPressedBrush}" />
</Style>
<Style Selector="Button.step-nav-item:disabled">
<Setter Property="Background" Value="Transparent" />
<Setter Property="Opacity" Value="1" />
</Style>
<Style Selector="Button.step-nav-item:disabled TextBlock.step-title">
<Setter Property="Foreground" Value="{DynamicResource InstallerDisabledTextBrush}" />
</Style>
<Style Selector="Button.step-nav-item:disabled fi|FluentIcon">
<Setter Property="Foreground" Value="{DynamicResource InstallerDisabledTextBrush}" />
</Style>
<Style Selector="Border.step-nav-selected-fill">
<Setter Property="Background" Value="{DynamicResource InstallerSubtleFillBrush}" />
<Setter Property="CornerRadius" Value="{DynamicResource DesignCornerRadiusMd}" />
</Style>
<Style Selector="TextBlock.step-title">
<Setter Property="FontSize" Value="13" />
<Setter Property="FontWeight" Value="SemiBold" />
<Setter Property="Foreground" Value="{DynamicResource InstallerTextSecondaryBrush}" />
</Style>
<Style Selector="Border.info-panel">
<Setter Property="Background" Value="{DynamicResource InstallerSurfaceAltBrush}" />
<Setter Property="BorderBrush" Value="{DynamicResource InstallerBorderBrush}" />
<Setter Property="BorderThickness" Value="1" />
<Setter Property="CornerRadius" Value="{DynamicResource DesignCornerRadiusMd}" />
<Setter Property="Padding" Value="12" />
</Style>
<Style Selector="Border.content-card">
<Setter Property="Background" Value="{DynamicResource InstallerSurfaceBrush}" />
<Setter Property="BorderBrush" Value="{DynamicResource InstallerBorderBrush}" />
<Setter Property="BorderThickness" Value="1" />
<Setter Property="CornerRadius" Value="{DynamicResource DesignCornerRadiusLg}" />
<Setter Property="Padding" Value="20" />
</Style>
<Style Selector="Border.error-bar">
<Setter Property="Background" Value="{DynamicResource InstallerErrorBackgroundBrush}" />
<Setter Property="BorderBrush" Value="{DynamicResource InstallerErrorBorderBrush}" />
<Setter Property="BorderThickness" Value="1" />
<Setter Property="CornerRadius" Value="{DynamicResource DesignCornerRadiusMd}" />
<Setter Property="Padding" Value="12" />
</Style>
<Style Selector="TextBlock.meta-label">
<Setter Property="FontSize" Value="12" />
<Setter Property="Foreground" Value="{DynamicResource InstallerTextTertiaryBrush}" />
</Style>
<Style Selector="TextBlock.meta-value">
<Setter Property="FontSize" Value="13" />
<Setter Property="FontWeight" Value="SemiBold" />
<Setter Property="Foreground" Value="{DynamicResource InstallerTextPrimaryBrush}" />
<Setter Property="TextWrapping" Value="Wrap" />
</Style>
<Style Selector="Border.separator">
<Setter Property="Height" Value="1" />
<Setter Property="Background" Value="{DynamicResource InstallerBorderBrush}" />
</Style>
</Window.Styles>
<Border Background="{DynamicResource InstallerWindowBackgroundBrush}"
CornerRadius="{DynamicResource DesignCornerRadiusXl}"
ClipToBounds="True">
<Grid x:Name="RootGrid"
RowDefinitions="48,*"
Background="Transparent">
<Border Grid.Row="0"
Background="{DynamicResource InstallerWindowBackgroundBrush}"
PointerPressed="OnTitleBarPointerPressed">
<Grid ColumnDefinitions="Auto,*,Auto">
<StackPanel Orientation="Horizontal"
Margin="16,0,0,0"
Spacing="10"
VerticalAlignment="Center">
<Border Width="28"
Height="28"
Background="{DynamicResource InstallerAccentBrush}"
CornerRadius="{DynamicResource DesignCornerRadiusSm}">
<fi:FluentIcon Icon="ArrowDownload"
IconVariant="Regular"
Foreground="{DynamicResource InstallerOnAccentBrush}"
FontSize="16" />
</Border>
<TextBlock Text="{Binding WindowTitle}"
FontSize="13"
FontWeight="SemiBold"
VerticalAlignment="Center" />
</StackPanel>
<StackPanel Grid.Column="2"
Orientation="Horizontal"
Spacing="2"
Margin="0,0,8,0"
VerticalAlignment="Center">
<Button Classes="titlebar-icon-button"
ToolTip.Tip="最小化"
Click="OnMinimizeClick">
<fi:FluentIcon Icon="Subtract"
IconVariant="Regular"
FontSize="14" />
</Button>
<Button Classes="titlebar-icon-button"
ToolTip.Tip="关闭"
Click="OnCloseClick">
<fi:FluentIcon Icon="Dismiss"
IconVariant="Regular"
FontSize="14" />
</Button>
</StackPanel>
</Grid>
</Border>
<Grid Grid.Row="1"
ColumnDefinitions="260,10,*"
Margin="10,0,10,10">
<Border Grid.Column="0"
Background="{DynamicResource InstallerPaneBackgroundBrush}"
CornerRadius="{DynamicResource DesignCornerRadiusLg}"
Padding="22,24">
<Grid RowDefinitions="Auto,*,Auto">
<StackPanel Spacing="8">
<TextBlock Text="阑山桌面"
FontSize="22"
FontWeight="SemiBold" />
<TextBlock Text="在线安装程序"
Classes="caption-text" />
</StackPanel>
<ItemsControl Grid.Row="1"
Margin="0,28,0,0"
ItemsSource="{Binding Steps}">
<ItemsControl.ItemTemplate>
<DataTemplate x:DataType="vm:InstallerStepViewModel">
<Button Classes="step-nav-item"
Command="{Binding #Root.DataContext.SelectStepCommand}"
CommandParameter="{Binding}"
IsEnabled="{Binding IsUnlocked}">
<Grid MinHeight="40">
<Border Classes="step-nav-selected-fill"
IsVisible="{Binding IsSelected}" />
<Grid ColumnDefinitions="Auto,*"
ColumnSpacing="10"
Margin="10,0">
<Grid Width="18"
VerticalAlignment="Center">
<fi:FluentIcon Icon="{Binding Icon}"
IconVariant="Regular"
Foreground="{DynamicResource InstallerTextSecondaryBrush}"
FontSize="17"
IsVisible="{Binding !IsSelected}" />
<fi:FluentIcon Icon="{Binding Icon}"
IconVariant="Filled"
Foreground="{DynamicResource InstallerTextPrimaryBrush}"
FontSize="17"
IsVisible="{Binding IsSelected}" />
</Grid>
<Grid Grid.Column="1"
VerticalAlignment="Center">
<TextBlock Classes="step-title"
Text="{Binding Title}"
IsVisible="{Binding !IsSelected}" />
<TextBlock Classes="step-title"
Text="{Binding Title}"
Foreground="{DynamicResource InstallerTextPrimaryBrush}"
IsVisible="{Binding IsSelected}" />
</Grid>
</Grid>
</Grid>
</Button>
</DataTemplate>
</ItemsControl.ItemTemplate>
</ItemsControl>
<TextBlock Grid.Row="2"
Classes="caption-text"
Text="安装期间请保持网络连接。下载失败时可返回上一步重新检查。" />
</Grid>
</Border>
<Border Grid.Column="2"
Background="{DynamicResource InstallerContentBackgroundBrush}"
CornerRadius="{DynamicResource DesignCornerRadiusLg}"
ClipToBounds="True">
<Grid RowDefinitions="*,Auto"
Background="Transparent">
<ScrollViewer Grid.Row="0"
Padding="36,34,42,24"
VerticalScrollBarVisibility="Auto">
<Grid>
<Grid Classes="step-page"
IsVisible="{Binding IsWelcomeStep}">
<StackPanel Classes="installer-page-container">
<StackPanel Spacing="8">
<TextBlock Classes="page-title-text"
Text="安装阑山桌面" />
<TextBlock Classes="page-description-text"
Text="在线安装程序会获取最新完整包,并把应用部署到本机版本目录。" />
</StackPanel>
<Border Classes="content-card">
<Grid ColumnDefinitions="Auto,*"
ColumnSpacing="14">
<Border Width="40"
Height="40"
Background="{DynamicResource InstallerSubtleFillBrush}"
CornerRadius="{DynamicResource DesignCornerRadiusMd}">
<fi:FluentIcon Icon="CloudArrowDown"
IconVariant="Regular"
FontSize="20" />
</Border>
<StackPanel Grid.Column="1"
Spacing="6">
<TextBlock Text="准备开始"
FontSize="16"
FontWeight="SemiBold" />
<TextBlock Text="安装器会检查最新版本、下载完整包、校验文件并激活部署。"
Classes="muted" />
</StackPanel>
</Grid>
</Border>
</StackPanel>
</Grid>
<Grid Classes="step-page"
IsVisible="{Binding IsLocationStep}">
<StackPanel Classes="installer-page-container">
<StackPanel Spacing="8">
<TextBlock Classes="page-title-text"
Text="选择安装位置" />
<TextBlock Classes="page-description-text"
Text="请选择一个专用文件夹。默认位置需要管理员权限,和现有安装方式保持一致。" />
</StackPanel>
<Border Classes="content-card">
<StackPanel Spacing="16">
<StackPanel Spacing="6">
<TextBlock Text="安装目录"
FontSize="16"
FontWeight="SemiBold" />
<TextBlock Text="安装根目录下会创建 .Launcher 和 app-{version}-0。"
Classes="muted" />
</StackPanel>
<Grid ColumnDefinitions="*,Auto"
ColumnSpacing="10">
<TextBox Text="{Binding InstallPath, Mode=TwoWay}"
PlaceholderText="安装路径" />
<Button Grid.Column="1"
Classes="secondary-command"
Command="{Binding BrowseCommand}">
<StackPanel Orientation="Horizontal"
Spacing="6">
<fi:FluentIcon Icon="FolderOpen"
IconVariant="Regular" />
<TextBlock Text="浏览" />
</StackPanel>
</Button>
</Grid>
<CheckBox IsChecked="{Binding CreateDesktopShortcut}"
Content="创建桌面快捷方式" />
<CheckBox IsChecked="{Binding CreateStartupShortcut}"
Content="开机时自动启动阑山桌面" />
</StackPanel>
</Border>
</StackPanel>
</Grid>
<Grid Classes="step-page"
IsVisible="{Binding IsPrivacyStep}">
<StackPanel Classes="installer-page-container">
<StackPanel Spacing="8">
<TextBlock Classes="page-title-text"
Text="确认数据使用" />
<TextBlock Classes="page-description-text"
Text="安装阶段需要使用匿名设备码和基础请求信息,用于安装、风控和用户量统计。" />
</StackPanel>
<Border Classes="content-card">
<StackPanel Spacing="16">
<StackPanel Spacing="6">
<TextBlock Text="匿名设备码"
FontSize="16"
FontWeight="SemiBold" />
<TextBlock Text="{Binding DeviceIdPreview}"
TextWrapping="Wrap"
FontFamily="Consolas"
Foreground="{DynamicResource InstallerTextSecondaryBrush}" />
</StackPanel>
<Border Classes="info-panel">
<Grid ColumnDefinitions="Auto,*"
ColumnSpacing="10">
<fi:FluentIcon Icon="Shield"
IconVariant="Regular"
Foreground="{DynamicResource InstallerAccentBrush}"
FontSize="18" />
<TextBlock Grid.Column="1"
Text="安装器会发送匿名设备码、系统与架构信息、目标版本和请求 IP不会上传用户名、机器名或安装目录。"
Classes="muted" />
</Grid>
</Border>
<CheckBox IsChecked="{Binding PrivacyConfirmed}"
Content="我确认上述匿名数据可用于安装、风控和用户量统计。" />
</StackPanel>
</Border>
</StackPanel>
</Grid>
<Grid Classes="step-page"
IsVisible="{Binding IsDeployStep}">
<StackPanel Classes="installer-page-container">
<StackPanel Spacing="8">
<TextBlock Classes="page-title-text"
Text="开始部署" />
<TextBlock Classes="page-description-text"
Text="安装时会下载完整包,并写入当前版本目录。" />
</StackPanel>
<Border Classes="content-card">
<StackPanel Spacing="18">
<Grid ColumnDefinitions="Auto,*"
RowDefinitions="Auto,Auto,Auto"
ColumnSpacing="18"
RowSpacing="10">
<TextBlock Classes="meta-label"
Text="版本" />
<TextBlock Grid.Column="1"
Classes="meta-value"
Text="{Binding TargetVersion}" />
<Border Grid.Row="1"
Grid.ColumnSpan="2"
Classes="separator" />
<TextBlock Grid.Row="2"
Classes="meta-label"
Text="来源" />
<TextBlock Grid.Row="2"
Grid.Column="1"
Classes="meta-value"
Text="{Binding SourceId}" />
</Grid>
<StackPanel Spacing="8">
<TextBlock Text="{Binding StatusText}"
FontWeight="SemiBold" />
<ProgressBar Minimum="0"
Maximum="1"
Value="{Binding DownloadProgress}" />
<TextBlock Classes="caption-text"
Text="{Binding DownloadBytesText}" />
</StackPanel>
<StackPanel Spacing="8">
<TextBlock Text="安装进度"
FontWeight="SemiBold" />
<ProgressBar Minimum="0"
Maximum="1"
Value="{Binding InstallProgress}" />
<TextBlock Classes="caption-text"
Text="{Binding CurrentFile}" />
</StackPanel>
<StackPanel Orientation="Horizontal"
Spacing="8">
<Button Classes="primary-command"
Command="{Binding StartInstallCommand}">
<StackPanel Orientation="Horizontal"
Spacing="6">
<fi:FluentIcon Icon="ArrowDownload"
IconVariant="Regular" />
<TextBlock Text="开始安装" />
</StackPanel>
</Button>
<Button Classes="secondary-command"
Command="{Binding CancelInstallCommand}"
IsEnabled="{Binding IsInstalling}">
<StackPanel Orientation="Horizontal"
Spacing="6">
<fi:FluentIcon Icon="Dismiss"
IconVariant="Regular" />
<TextBlock Text="取消" />
</StackPanel>
</Button>
</StackPanel>
</StackPanel>
</Border>
</StackPanel>
</Grid>
<Grid Classes="step-page"
IsVisible="{Binding IsCompleteStep}">
<StackPanel Classes="installer-page-container">
<StackPanel Spacing="8">
<TextBlock Classes="page-title-text"
Text="完成安装" />
<TextBlock Classes="page-description-text"
Text="阑山桌面已经部署完成。" />
</StackPanel>
<Border Classes="content-card">
<Grid ColumnDefinitions="Auto,*"
ColumnSpacing="14">
<Border Width="40"
Height="40"
Background="{DynamicResource InstallerSubtleFillBrush}"
CornerRadius="{DynamicResource DesignCornerRadiusMd}">
<fi:FluentIcon Icon="CheckmarkCircle"
IconVariant="Regular"
Foreground="{DynamicResource InstallerSuccessBrush}"
FontSize="22" />
</Border>
<StackPanel Grid.Column="1"
Spacing="12">
<StackPanel Spacing="5">
<TextBlock Text="可以启动应用"
FontSize="16"
FontWeight="SemiBold" />
<TextBlock Text="使用 Launcher 进入首次启动流程。"
Classes="muted" />
</StackPanel>
<Button Classes="primary-command"
HorizontalAlignment="Left"
Command="{Binding LaunchCommand}">
<StackPanel Orientation="Horizontal"
Spacing="6">
<fi:FluentIcon Icon="Play"
IconVariant="Regular" />
<TextBlock Text="打开阑山桌面" />
</StackPanel>
</Button>
</StackPanel>
</Grid>
</Border>
</StackPanel>
</Grid>
</Grid>
</ScrollViewer>
<Border Grid.Row="1"
Background="Transparent"
Padding="36,16,42,18">
<Grid ColumnDefinitions="*,Auto,Auto"
ColumnSpacing="8">
<Border Classes="error-bar"
IsVisible="{Binding HasError}">
<Grid ColumnDefinitions="Auto,*"
ColumnSpacing="10">
<fi:FluentIcon Icon="ErrorCircle"
IconVariant="Regular"
Foreground="{DynamicResource InstallerErrorBrush}"
FontSize="18" />
<TextBlock Grid.Column="1"
Text="{Binding ErrorMessage}"
Foreground="{DynamicResource InstallerErrorBrush}"
TextWrapping="Wrap"
VerticalAlignment="Center" />
</Grid>
</Border>
<Button Grid.Column="1"
Classes="secondary-command"
Command="{Binding BackCommand}">
<StackPanel Orientation="Horizontal"
Spacing="6">
<fi:FluentIcon Icon="ArrowLeft"
IconVariant="Regular" />
<TextBlock Text="上一步" />
</StackPanel>
</Button>
<Button Grid.Column="2"
Classes="primary-command"
Command="{Binding NextCommand}">
<StackPanel Orientation="Horizontal"
Spacing="6">
<TextBlock Text="下一步" />
<fi:FluentIcon Icon="ArrowRight"
IconVariant="Regular" />
</StackPanel>
</Button>
</Grid>
</Border>
</Grid>
</Border>
</Grid>
</Grid>
</Border>
</Window>

View File

@@ -0,0 +1,81 @@
using Avalonia.Controls;
using Avalonia.Input;
using Avalonia.Interactivity;
using Avalonia.Platform.Storage;
using LanDesktopPLONDS.Installer.ViewModels;
namespace LanDesktopPLONDS.Installer.Views;
public partial class MainWindow : Window
{
public MainWindow()
{
InitializeComponent();
}
protected override void OnDataContextChanged(EventArgs e)
{
base.OnDataContextChanged(e);
if (DataContext is MainWindowViewModel viewModel)
{
viewModel.BrowseRequested = BrowseForFolderAsync;
}
}
private async Task<string?> BrowseForFolderAsync(string currentPath)
{
IStorageFolder? startFolder = null;
if (Directory.Exists(currentPath))
{
startFolder = await StorageProvider.TryGetFolderFromPathAsync(currentPath);
}
var result = await StorageProvider.OpenFolderPickerAsync(new FolderPickerOpenOptions
{
Title = "选择安装位置",
AllowMultiple = false,
SuggestedStartLocation = startFolder
});
if (result.Count == 0)
{
return null;
}
var path = result[0].TryGetLocalPath();
if (string.IsNullOrWhiteSpace(path))
{
throw new InvalidOperationException("请选择本机文件夹作为安装位置。");
}
return path;
}
private void OnTitleBarPointerPressed(object? sender, PointerPressedEventArgs e)
{
_ = sender;
if (e.Source is Button)
{
return;
}
if (e.GetCurrentPoint(this).Properties.IsLeftButtonPressed)
{
BeginMoveDrag(e);
}
}
private void OnMinimizeClick(object? sender, RoutedEventArgs e)
{
_ = sender;
_ = e;
WindowState = WindowState.Minimized;
}
private void OnCloseClick(object? sender, RoutedEventArgs e)
{
_ = sender;
_ = e;
Close();
}
}

View File

@@ -0,0 +1,18 @@
<?xml version="1.0" encoding="utf-8"?>
<assembly manifestVersion="1.0" xmlns="urn:schemas-microsoft-com:asm.v1">
<assemblyIdentity version="0.0.0.0" name="LanDesktopPLONDS.Installer"/>
<trustInfo xmlns="urn:schemas-microsoft-com:asm.v2">
<security>
<requestedPrivileges xmlns="urn:schemas-microsoft-com:asm.v3">
<requestedExecutionLevel level="asInvoker" uiAccess="false" />
</requestedPrivileges>
</security>
</trustInfo>
<compatibility xmlns="urn:schemas-microsoft-com:compatibility.v1">
<application>
<supportedOS Id="{8e0f7a12-bfb3-4fe8-b9a5-48fd50a15a9a}" />
</application>
</compatibility>
</assembly>

View File

@@ -0,0 +1,18 @@
<?xml version="1.0" encoding="utf-8"?>
<assembly manifestVersion="1.0" xmlns="urn:schemas-microsoft-com:asm.v1">
<assemblyIdentity version="0.0.0.0" name="LanDesktopPLONDS.Installer"/>
<trustInfo xmlns="urn:schemas-microsoft-com:asm.v2">
<security>
<requestedPrivileges xmlns="urn:schemas-microsoft-com:asm.v3">
<requestedExecutionLevel level="requireAdministrator" uiAccess="false" />
</requestedPrivileges>
</security>
</trustInfo>
<compatibility xmlns="urn:schemas-microsoft-com:compatibility.v1">
<application>
<supportedOS Id="{8e0f7a12-bfb3-4fe8-b9a5-48fd50a15a9a}" />
</application>
</compatibility>
</assembly>

View File

@@ -1,10 +1,15 @@
namespace LanMountainDesktop.Launcher.Services.AirApp;
namespace LanMountainDesktop.AirAppRuntime;
internal sealed class AirAppHostLocator
{
private const string WindowsExecutableName = "LanMountainDesktop.AirAppHost.exe";
private const string UnixExecutableName = "LanMountainDesktop.AirAppHost";
private const string DllName = "LanMountainDesktop.AirAppHost.dll";
private static string ExecutableName => OperatingSystem.IsWindows()
? WindowsExecutableName
: UnixExecutableName;
public string Resolve(string? packageRoot, string? hostPath = null)
{
foreach (var candidate in EnumerateCandidates(packageRoot, hostPath))
@@ -22,18 +27,18 @@ internal sealed class AirAppHostLocator
{
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, WindowsExecutableName);
yield return Path.Combine(root, ExecutableName);
yield return Path.Combine(root, DllName);
if (Directory.Exists(root))
{
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, WindowsExecutableName);
yield return Path.Combine(deploymentDirectory, ExecutableName);
yield return Path.Combine(deploymentDirectory, DllName);
}
}
@@ -52,7 +57,7 @@ internal sealed class AirAppHostLocator
"Release",
#endif
"net10.0",
WindowsExecutableName);
ExecutableName);
yield return Path.Combine(
current.FullName,

View File

@@ -1,4 +1,4 @@
namespace LanMountainDesktop.Launcher.Services.AirApp;
namespace LanMountainDesktop.AirAppRuntime;
internal static class AirAppInstanceKey
{
@@ -17,8 +17,6 @@ internal static class AirAppInstanceKey
private static string Normalize(string? value, string fallback)
{
return string.IsNullOrWhiteSpace(value)
? fallback
: value.Trim();
return string.IsNullOrWhiteSpace(value) ? fallback : value.Trim();
}
}

View File

@@ -2,15 +2,15 @@ using System.Diagnostics;
using System.Runtime.InteropServices;
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 IAirAppProcessStarter _processStarter;
private readonly Dictionary<string, ManagedAirAppInstance> _instances = new(StringComparer.OrdinalIgnoreCase);
public LauncherAirAppLifecycleService(IAirAppProcessStarter processStarter)
public AirAppLifecycleService(IAirAppProcessStarter processStarter)
{
_processStarter = processStarter;
}
@@ -20,7 +20,7 @@ internal sealed class LauncherAirAppLifecycleService : IAirAppLifecycleService
ArgumentNullException.ThrowIfNull(request);
var appId = Normalize(request.AppId, "unknown");
var instanceKey = AirAppInstanceKey.Build(appId, request.SourceComponentId, request.SourcePlacementId);
Logger.Info(
AirAppRuntimeLogger.Info(
$"Air APP open requested. AppId='{appId}'; InstanceKey='{instanceKey}'; RequesterProcessId={request.RequesterProcessId}.");
lock (_gate)
@@ -57,12 +57,12 @@ internal sealed class LauncherAirAppLifecycleService : IAirAppLifecycleService
request.SourceComponentId,
request.SourcePlacementId);
_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));
}
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));
}
}
@@ -134,7 +134,7 @@ internal sealed class LauncherAirAppLifecycleService : IAirAppLifecycleService
request.SourceComponentId,
request.SourcePlacementId);
_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));
}
}
@@ -147,7 +147,7 @@ internal sealed class LauncherAirAppLifecycleService : IAirAppLifecycleService
(processId <= 0 || instance.ProcessId == processId))
{
_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));
}
@@ -174,7 +174,7 @@ internal sealed class LauncherAirAppLifecycleService : IAirAppLifecycleService
foreach (var key in exitedKeys)
{
_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)
{
@@ -257,9 +257,7 @@ internal sealed class LauncherAirAppLifecycleService : IAirAppLifecycleService
private static string Normalize(string? value, string fallback)
{
return string.IsNullOrWhiteSpace(value)
? fallback
: value.Trim();
return string.IsNullOrWhiteSpace(value) ? fallback : value.Trim();
}
private const int SW_SHOWNORMAL = 1;

View File

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

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

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

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

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

View File

@@ -1,7 +1,7 @@
using System.Diagnostics;
using LanMountainDesktop.Launcher.Services;
using LanMountainDesktop.Shared.IPC;
namespace LanMountainDesktop.Launcher.Services.AirApp;
namespace LanMountainDesktop.AirAppRuntime;
internal interface IAirAppProcessStarter
{
@@ -14,20 +14,17 @@ internal sealed class AirAppProcessStarter : IAirAppProcessStarter
private readonly Func<string?> _packageRootProvider;
private readonly Func<string?> _hostPathProvider;
private readonly Func<string?> _dataRootProvider;
private readonly DotNetRuntimeProbeOptions? _runtimeProbeOptions;
public AirAppProcessStarter(
AirAppHostLocator locator,
Func<string?> packageRootProvider,
Func<string?> hostPathProvider,
Func<string?> dataRootProvider,
DotNetRuntimeProbeOptions? runtimeProbeOptions = null)
Func<string?> dataRootProvider)
{
_locator = locator;
_packageRootProvider = packageRootProvider;
_hostPathProvider = hostPathProvider;
_dataRootProvider = dataRootProvider;
_runtimeProbeOptions = runtimeProbeOptions;
}
public Process? Start(
@@ -38,12 +35,12 @@ internal sealed class AirAppProcessStarter : IAirAppProcessStarter
string? sourcePlacementId)
{
var hostPath = _locator.Resolve(_packageRootProvider(), _hostPathProvider());
var startInfo = CreateStartInfo(hostPath, _runtimeProbeOptions);
var startInfo = CreateStartInfo(hostPath);
AddArgument(startInfo, "--app-id", appId);
AddArgument(startInfo, "--session-id", sessionId);
AddArgument(startInfo, "--instance-key", instanceKey);
AddArgument(startInfo, "--launcher-pipe", LanMountainDesktop.Shared.IPC.IpcConstants.AirAppLifecyclePipeName);
AddArgument(startInfo, "--launcher-pipe", IpcConstants.AirAppRuntimePipeName);
var dataRoot = _dataRootProvider();
if (!string.IsNullOrWhiteSpace(dataRoot))
{
@@ -60,7 +57,7 @@ internal sealed class AirAppProcessStarter : IAirAppProcessStarter
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}'.");
var process = Process.Start(startInfo);
if (process is not null)
@@ -70,12 +67,12 @@ internal sealed class AirAppProcessStarter : IAirAppProcessStarter
{
try
{
LanMountainDesktop.Launcher.Services.Logger.Info(
AirAppRuntimeLogger.Info(
$"AirAppHost exited. AppId='{appId}'; InstanceKey='{instanceKey}'; ProcessId={process.Id}; ExitCode={process.ExitCode}.");
}
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,54 +80,11 @@ internal sealed class AirAppProcessStarter : IAirAppProcessStarter
return process;
}
internal static ProcessStartInfo CreateStartInfo(
string hostPath,
DotNetRuntimeProbeOptions? runtimeProbeOptions = null)
internal static ProcessStartInfo CreateStartInfo(string hostPath)
{
var startInfo = new ProcessStartInfo
{
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;
return AirAppRuntimeProcessStarter.CreateStartInfo(hostPath);
}
private static void AddArgument(ProcessStartInfo startInfo, string name, string value)
{
startInfo.ArgumentList.Add(name);

View File

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

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

View File

@@ -0,0 +1,3 @@
using System.Runtime.CompilerServices;
[assembly: InternalsVisibleTo("LanMountainDesktop.Tests")]

View File

@@ -1,18 +1,11 @@
using System.Diagnostics;
using Avalonia;
using Avalonia.Controls;
using Avalonia.Controls.ApplicationLifetimes;
using Avalonia.Markup.Xaml;
using Avalonia.Threading;
using LanMountainDesktop.Launcher.Models;
using LanMountainDesktop.Launcher.Resources;
using LanMountainDesktop.Launcher.Services;
using LanMountainDesktop.Launcher.Services.AirApp;
using LanMountainDesktop.Launcher.Services.Ipc;
using LanMountainDesktop.Launcher.Shell;
using LanMountainDesktop.Launcher.Shell.EntryHandlers;
using LanMountainDesktop.Launcher.Views;
using LanMountainDesktop.Shared.Contracts.Launcher;
using LanMountainDesktop.Shared.IPC;
using LanMountainDesktop.Shared.IPC.Abstractions.Services;
namespace LanMountainDesktop.Launcher;
@@ -46,804 +39,39 @@ public partial class App : Application
return;
}
if (ApplicationLifetime is IClassicDesktopStyleApplicationLifetime desktop)
if (ApplicationLifetime is not IClassicDesktopStyleApplicationLifetime desktop)
{
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 (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);
}
base.OnFrameworkInitializationCompleted();
return;
}
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();
}
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);
}
}

View File

@@ -1,7 +1,7 @@
using System.Text.Json;
using System.Text.Json.Serialization;
using LanMountainDesktop.Launcher.Models;
using LanMountainDesktop.Launcher.Services;
using LanMountainDesktop.Launcher.Plugins;
using LanMountainDesktop.Shared.Contracts.Launcher;
using LanMountainDesktop.Shared.IPC.Abstractions.Services;
@@ -11,15 +11,7 @@ namespace LanMountainDesktop.Launcher;
WriteIndented = true,
PropertyNamingPolicy = JsonKnownNamingPolicy.CamelCase,
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(InstallCheckpoint))]
[JsonSerializable(typeof(AppVersionInfo))]
[JsonSerializable(typeof(StartupProgressMessage))]
[JsonSerializable(typeof(LauncherCoordinatorRequest))]
@@ -30,18 +22,18 @@ namespace LanMountainDesktop.Launcher;
[JsonSerializable(typeof(PublicTaskbarStatus))]
[JsonSerializable(typeof(PublicShellActivationResult))]
[JsonSerializable(typeof(LauncherResult))]
[JsonSerializable(typeof(HostDiscoveryConfig))]
[JsonSerializable(typeof(PluginManifest))]
[JsonSerializable(typeof(PendingUpgrade))]
[JsonSerializable(typeof(List<PendingUpgrade>))]
[JsonSerializable(typeof(OobeStateFile))]
[JsonSerializable(typeof(DataLocationConfig))]
[JsonSerializable(typeof(GitHubRelease))]
[JsonSerializable(typeof(GitHubAsset))]
[JsonSerializable(typeof(List<GitHubRelease>))]
[JsonSerializable(typeof(StartupAttemptRecord))]
[JsonSerializable(typeof(PrivacyConfig))]
[JsonSerializable(typeof(PrivacyAgreementState))]
[JsonSerializable(typeof(LanMountainDesktop.Shared.Contracts.Update.InstallProgressReport))]
[JsonSerializable(typeof(LanMountainDesktop.Shared.Contracts.Update.InstallCompleteReport))]
[JsonSerializable(typeof(AirAppOpenRequest))]
[JsonSerializable(typeof(AirAppRegistrationRequest))]
[JsonSerializable(typeof(AirAppInstanceInfo))]
[JsonSerializable(typeof(AirAppOperationResult))]
[JsonSerializable(typeof(AirAppInstanceInfo[]))]
[JsonSerializable(typeof(AirAppRuntimeControlResult))]
[JsonSerializable(typeof(AirAppRuntimeStatus))]
internal sealed partial class AppJsonContext : JsonSerializerContext;

View File

@@ -4,15 +4,11 @@ namespace LanMountainDesktop.Launcher;
internal sealed class CommandContext
{
public const string AirAppBrokerCommand = "air-app-broker";
private const string LaunchSourceOptionName = "launch-source";
private static readonly string[] GuiCommands =
[
"launch",
AirAppBrokerCommand,
"apply-update",
"preview-splash",
"preview-error",
"preview-update",
@@ -63,16 +59,11 @@ internal sealed class CommandContext
public bool IsPreviewCommand =>
Command.StartsWith("preview-", StringComparison.OrdinalIgnoreCase);
public bool IsAirAppBrokerCommand =>
string.Equals(Command, AirAppBrokerCommand, StringComparison.OrdinalIgnoreCase);
public bool IsGuiCommand =>
GuiCommands.Contains(Command, StringComparer.OrdinalIgnoreCase);
public bool IsMaintenanceCommand =>
string.Equals(LaunchSource, "apply-update", StringComparison.OrdinalIgnoreCase) ||
string.Equals(LaunchSource, "plugin-install", StringComparison.OrdinalIgnoreCase) ||
string.Equals(Command, "update", StringComparison.OrdinalIgnoreCase) ||
string.Equals(Command, "plugin", StringComparison.OrdinalIgnoreCase);
public string? ExplicitAppRoot => GetOption("app-root");
@@ -118,11 +109,6 @@ internal sealed class CommandContext
return "debug-preview";
}
if (string.Equals(Command, "apply-update", StringComparison.OrdinalIgnoreCase))
{
return "apply-update";
}
if (IsLegacyPluginInstall || string.Equals(Command, "plugin", StringComparison.OrdinalIgnoreCase))
{
return "plugin-install";
@@ -143,7 +129,6 @@ internal sealed class CommandContext
"normal" => "normal",
"restart" => "restart",
"postinstall" => "postinstall",
"apply-update" => "apply-update",
"plugin-install" => "plugin-install",
"debug-preview" => "debug-preview",
_ => null

View File

@@ -3,7 +3,7 @@ using System.Text.Json;
using LanMountainDesktop.Launcher.Models;
using LanMountainDesktop.Shared.Contracts.Launcher;
namespace LanMountainDesktop.Launcher.Services;
namespace LanMountainDesktop.Launcher.Deployment;
internal sealed class DeploymentLocator
{

View File

@@ -1,4 +1,4 @@
namespace LanMountainDesktop.Launcher.Services;
namespace LanMountainDesktop.Launcher.Deployment;
/// <summary>
/// 主程序发现选项

View File

@@ -1,6 +1,6 @@
using LanMountainDesktop.Shared.Contracts.Launcher;
namespace LanMountainDesktop.Launcher.Services;
namespace LanMountainDesktop.Launcher.Deployment;
internal sealed record HostLaunchPlan(
string HostPath,

View File

@@ -1,4 +1,4 @@
namespace LanMountainDesktop.Launcher.Services;
namespace LanMountainDesktop.Launcher.Deployment;
internal sealed class HostResolutionResult
{

View File

@@ -1,7 +1,7 @@
using System.Diagnostics;
using Microsoft.Win32;
namespace LanMountainDesktop.Launcher.Services;
namespace LanMountainDesktop.Launcher.Deployment;
/// <summary>
/// 老版本检测器 - 检测 0.8.x 及更早的单应用模式安装

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

View File

@@ -2,7 +2,7 @@ using System.Text;
using System.Text.Json;
using LanMountainDesktop.Launcher.Models;
namespace LanMountainDesktop.Launcher.Services;
namespace LanMountainDesktop.Launcher.Infrastructure;
internal static class Commands
{
@@ -14,7 +14,7 @@ internal static class Commands
{
var source = context.GetOption("source") ?? 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)
{
@@ -35,15 +35,14 @@ internal static class Commands
public static async Task<int> RunCliCommandAsync(CommandContext context)
{
var appRoot = ResolveAppRoot(context);
var deploymentLocator = new DeploymentLocator(appRoot);
var updateEngine = new UpdateEngineService(deploymentLocator);
_ = new DeploymentLocator(appRoot);
var pluginInstaller = new PluginInstallerService();
var pluginUpgrades = new PluginUpgradeQueueService(pluginInstaller);
LauncherResult result;
try
{
result = await ExecuteCoreAsync(context, updateEngine, pluginInstaller, pluginUpgrades).ConfigureAwait(false);
result = ExecuteCore(context, pluginInstaller, pluginUpgrades);
}
catch (Exception ex)
{
@@ -61,16 +60,13 @@ internal static class Commands
return result.Success ? 0 : 1;
}
private static async Task<LauncherResult> ExecuteCoreAsync(
private static LauncherResult ExecuteCore(
CommandContext context,
UpdateEngineService updateEngine,
PluginInstallerService pluginInstaller,
PluginUpgradeQueueService pluginUpgrades)
{
switch (context.Command.ToLowerInvariant())
{
case "update":
return await ExecuteUpdateAsync(context, updateEngine).ConfigureAwait(false);
case "plugin":
return ExecutePluginCommand(context, pluginInstaller, pluginUpgrades);
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(
CommandContext context,
PluginInstallerService pluginInstaller,
@@ -122,12 +91,12 @@ internal static class Commands
{
var source = context.GetOption("source") ?? throw new InvalidOperationException("Missing --source.");
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":
{
var pluginsDir = context.GetOption("plugins-dir") ?? throw new InvalidOperationException("Missing --plugins-dir.");
return pluginUpgrades.ApplyPendingUpgrades(pluginsDir);
return pluginUpgrades.ApplyPendingUpgrades(pluginsDir, context.ExplicitAppRoot);
}
default:
return new LauncherResult
@@ -171,16 +140,12 @@ internal static class Commands
? launcherDir
: AppContext.BaseDirectory);
// 发布版结构Launcher 和 app-* 目录在同一目录
// 检查当前目录是否有 app-* 子目录(发布版)
var appDirs = Directory.GetDirectories(baseDir, "app-*", SearchOption.TopDirectoryOnly);
if (appDirs.Length > 0)
{
// 找到 app-* 目录,说明是发布版结构
return baseDir;
}
// 开发环境:检查父目录是否有主程序
var parent = Path.GetFullPath(Path.Combine(baseDir, ".."));
var parentHost = OperatingSystem.IsWindows()
? Path.Combine(parent, "LanMountainDesktop.exe")
@@ -190,7 +155,6 @@ internal static class Commands
return parent;
}
// 默认返回 baseDir
return baseDir;
}
}

View File

@@ -1,7 +1,7 @@
using System.Text.Json;
using LanMountainDesktop.Launcher.Models;
namespace LanMountainDesktop.Launcher.Services;
namespace LanMountainDesktop.Launcher.Infrastructure;
/// <summary>
/// 解析应用数据目录位置。
@@ -132,6 +132,27 @@ internal sealed class DataLocationResolver
return ResolveDataRoot(config);
}
public string ResolveDataRoot(DataLocationMode mode, string? customPath = null)
{
return ResolveDataRoot(BuildConfig(mode, customPath));
}
public DataLocationConfig BuildConfig(DataLocationMode mode, string? customPath = null)
{
var targetDataRoot = mode == DataLocationMode.Portable
? Path.GetFullPath(!string.IsNullOrWhiteSpace(customPath)
? customPath
: DefaultPortableDataPath)
: _defaultSystemDataPath;
return new DataLocationConfig
{
DataLocationMode = mode.ToString(),
SystemDataPath = _defaultSystemDataPath,
PortableDataPath = mode == DataLocationMode.Portable ? targetDataRoot : null
};
}
private string ResolveDataRoot(DataLocationConfig? config)
{
if (config is null)
@@ -193,16 +214,8 @@ internal sealed class DataLocationResolver
public bool ApplyLocationChoice(DataLocationMode mode, string? customPath = null, bool migrateExistingData = false)
{
var targetDataRoot = mode == DataLocationMode.Portable && !string.IsNullOrWhiteSpace(customPath)
? Path.GetFullPath(customPath)
: _defaultSystemDataPath;
var config = new DataLocationConfig
{
DataLocationMode = mode.ToString(),
SystemDataPath = _defaultSystemDataPath,
PortableDataPath = mode == DataLocationMode.Portable ? targetDataRoot : null
};
var config = BuildConfig(mode, customPath);
var targetDataRoot = ResolveDataRoot(config);
// 先创建目录结构
try

View File

@@ -2,7 +2,7 @@ using System.Diagnostics;
using System.Runtime.InteropServices;
using Microsoft.Win32;
namespace LanMountainDesktop.Launcher.Services;
namespace LanMountainDesktop.Launcher.Infrastructure;
internal enum DotNetRuntimeArchitecture
{

View File

@@ -1,4 +1,4 @@
namespace LanMountainDesktop.Launcher.Services;
namespace LanMountainDesktop.Launcher.Infrastructure;
internal interface ISplashStageReporter
{

View File

@@ -1,7 +1,7 @@
using System.Globalization;
using System.Text.Json.Nodes;
namespace LanMountainDesktop.Launcher.Services;
namespace LanMountainDesktop.Launcher.Infrastructure;
internal static class LanguagePreferenceService
{

View File

@@ -1,4 +1,4 @@
namespace LanMountainDesktop.Launcher.Services;
namespace LanMountainDesktop.Launcher.Infrastructure;
internal sealed record LauncherDebugSettings(bool DevModeEnabled, string? CustomHostPath);

View File

@@ -1,7 +1,7 @@
using System.Security.Principal;
using LanMountainDesktop.Launcher.Models;
namespace LanMountainDesktop.Launcher.Services;
namespace LanMountainDesktop.Launcher.Infrastructure;
internal static class LauncherExecutionContext
{

View File

@@ -1,6 +1,6 @@
using System.Text;
namespace LanMountainDesktop.Launcher.Services;
namespace LanMountainDesktop.Launcher.Infrastructure;
/// <summary>
/// 简单的日志记录器 - 同时输出到控制台和文件

View File

@@ -3,7 +3,7 @@ using System.Text;
using System.Text.Json;
using LanMountainDesktop.Launcher.Models;
namespace LanMountainDesktop.Launcher.Services.Ipc;
namespace LanMountainDesktop.Launcher.Ipc;
internal sealed class LauncherCoordinatorIpcClient
{

View File

@@ -4,7 +4,7 @@ using System.Text.Json;
using System.IO.Pipes;
using LanMountainDesktop.Launcher.Models;
namespace LanMountainDesktop.Launcher.Services.Ipc;
namespace LanMountainDesktop.Launcher.Ipc;
internal sealed class LauncherCoordinatorIpcServer : IDisposable
{

View File

@@ -52,6 +52,7 @@
</ItemGroup>
<!-- AOT 兼容性:某些包可能需要特殊处理 -->
<PropertyGroup Condition="'$(PublishAot)' == 'true'">
<!-- 忽略某些警告 -->
<SuppressTrimAnalysisWarnings>false</SuppressTrimAnalysisWarnings>
@@ -60,7 +61,8 @@
<!-- AOT 模式下禁用反射式 JSON 序列化,强制使用 Source Generator -->
<!-- 之前设置为 true 与 AOT 矛盾,导致 IL2026/IL3050 警告和运行时失败 -->
<JsonSerializerIsReflectionEnabledByDefault>false</JsonSerializerIsReflectionEnabledByDefault>
<!-- [Fix]: 必须设置为 true 以支持 dotnetCampus.Ipc 内部的反射序列化。相关类型的剪裁保护通过 AppJsonContext 保证 -->
<JsonSerializerIsReflectionEnabledByDefault>true</JsonSerializerIsReflectionEnabledByDefault>
<!-- 启用 ISerializable 支持(部分库需要) -->
<IsAotCompatible>true</IsAotCompatible>

View File

@@ -18,6 +18,7 @@
<ItemGroup>
<!-- 只引用 Shared.ContractsIPC 协议) -->
<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" />
</ItemGroup>
@@ -29,6 +30,7 @@
<PackageReference Include="Avalonia.Fonts.Inter" />
<PackageReference Include="ClassIsland.Markdown.Avalonia" />
<PackageReference Include="CommunityToolkit.Mvvm" />
<PackageReference Include="Microsoft.Extensions.DependencyInjection" />
<PackageReference Include="Tmds.DBus.Protocol" />
</ItemGroup>
@@ -44,7 +46,7 @@
<Target Name="CopyPublicKeyToLauncherDir" AfterTargets="Build">
<PropertyGroup>
<PublicKeySource>$(MSBuildProjectDirectory)\Assets\public-key.pem</PublicKeySource>
<PublicKeyDestDir>$(OutDir).launcher\update</PublicKeyDestDir>
<PublicKeyDestDir>$(OutDir).Launcher\update</PublicKeyDestDir>
</PropertyGroup>
<MakeDir Directories="$(PublicKeyDestDir)" />
<Copy SourceFiles="$(PublicKeySource)" DestinationFolder="$(PublicKeyDestDir)" SkipUnchangedFiles="true" />
@@ -53,7 +55,7 @@
<Target Name="CopyPublicKeyToPublishDir" AfterTargets="Publish">
<PropertyGroup>
<PublishedKeySource>$(MSBuildProjectDirectory)\Assets\public-key.pem</PublishedKeySource>
<PublishedKeyDestDir>$(PublishDir).launcher\update</PublishedKeyDestDir>
<PublishedKeyDestDir>$(PublishDir).Launcher\update</PublishedKeyDestDir>
</PropertyGroup>
<MakeDir Directories="$(PublishedKeyDestDir)" />
<Copy SourceFiles="$(PublishedKeySource)" DestinationFolder="$(PublishedKeyDestDir)" SkipUnchangedFiles="true" />

View File

@@ -57,6 +57,23 @@ internal sealed class OobeCompletionResult
public string ErrorMessage { get; init; } = string.Empty;
}
internal sealed class OobeSessionDraft
{
public DataLocationMode DataLocationMode { get; init; } = DataLocationMode.System;
public bool MigrateExistingData { get; init; }
public HostAppSettingsStartupChoices StartupChoices { get; init; }
public PrivacyConfig PrivacyConfig { get; init; } = new();
public bool PrivacyAgreementAccepted { get; init; }
public string PrivacyUserId { get; init; } = string.Empty;
public string PrivacyDeviceId { get; init; } = string.Empty;
}
internal sealed record LauncherExecutionSnapshot(
bool IsElevated,
string UserName,

View File

@@ -1,17 +0,0 @@
namespace LanMountainDesktop.Launcher.Models;
/// <summary>
/// 更新频道
/// </summary>
public enum UpdateChannel
{
/// <summary>
/// 正式版 - 只检查 prerelease=false 的版本
/// </summary>
Stable,
/// <summary>
/// 预览版 - 检查所有版本(包括 prerelease=true)
/// </summary>
Preview
}

View File

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

View File

@@ -1,29 +1,5 @@
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
{
public string SnapshotId { get; set; } = string.Empty;
@@ -40,124 +16,3 @@ internal sealed class SnapshotMetadata
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; }
}

View File

@@ -2,7 +2,7 @@ using System.Text.Json;
using System.Text.Json.Nodes;
using LanMountainDesktop.Shared.Contracts.Launcher;
namespace LanMountainDesktop.Launcher.Services;
namespace LanMountainDesktop.Launcher.Oobe;
/// <summary>
/// 在 OOBE 中向 Host 的 settings.json 写入启动与展示相关字段,属性名与 Host

Some files were not shown because too many files have changed in this diff Show More