mirror of
https://github.com/wwiinnddyy/LanMountainDesktop.git
synced 2026-06-20 23:54:26 +08:00
475 lines
14 KiB
Markdown
475 lines
14 KiB
Markdown
# 更新系统架构
|
||
|
||
本文档描述阑山桌面的更新系统设计,包括增量更新、原子化安装、版本管理和回滚机制。
|
||
|
||
## 更新系统概览
|
||
|
||
### 核心特性
|
||
|
||
- ✅ **增量更新** - 只下载变更文件,节省带宽
|
||
- ✅ **原子化更新** - 保证完整性,失败自动回滚
|
||
- ✅ **多版本共存** - 支持多版本并存和快速切换
|
||
- ✅ **签名验证** - RSA 签名确保安全性
|
||
- ✅ **版本回退** - 一键回退到上一版本
|
||
- ✅ **更新频道** - Stable/Preview 频道切换
|
||
- ✅ **后台下载** - 静默下载,不影响使用
|
||
|
||
### 架构组件
|
||
|
||
```
|
||
┌──────────────────────────────────────────────┐
|
||
│ LanMountainDesktop (Host) │
|
||
│ │
|
||
│ ┌────────────────────────────────────────┐ │
|
||
│ │ UpdateOrchestrator │ │
|
||
│ │ (更新编排 - Host 拥有) │ │
|
||
│ │ - 检查更新 │ │
|
||
│ │ - 下载更新 │ │
|
||
│ │ - 应用更新 │ │
|
||
│ │ - 回滚更新 │ │
|
||
│ └────────────────────────────────────────┘ │
|
||
│ │
|
||
│ ┌────────────────┐ ┌──────────────────┐ │
|
||
│ │UpdateInstall │ │UpdateRollback │ │
|
||
│ │ Gateway │ │ Gateway │ │
|
||
│ └────────────────┘ └──────────────────┘ │
|
||
└──────────────────────────────────────────────┘
|
||
↓ 写入 deployment.lock
|
||
┌──────────────────────────────────────────────┐
|
||
│ Launcher │
|
||
│ (启动器 - 版本选择和清理) │
|
||
│ │
|
||
│ ┌────────────────────────────────────────┐ │
|
||
│ │ DeploymentLocator │ │
|
||
│ │ 扫描和选择最佳版本 │ │
|
||
│ └────────────────────────────────────────┘ │
|
||
└──────────────────────────────────────────────┘
|
||
```
|
||
|
||
## 目录结构
|
||
|
||
### 版本目录布局
|
||
|
||
```
|
||
安装根目录/
|
||
├── LanMountainDesktop.Launcher.exe # 启动器
|
||
├── app-1.0.0/ # 旧版本
|
||
│ ├── .destroy # 待删除标记
|
||
│ ├── LanMountainDesktop.exe
|
||
│ └── ...
|
||
├── app-1.0.1/ # 当前版本
|
||
│ ├── .current # 当前版本标记
|
||
│ ├── LanMountainDesktop.exe
|
||
│ └── ...
|
||
├── app-1.0.2/ # 新版本(下载中)
|
||
│ ├── .partial # 未完成标记
|
||
│ └── ...
|
||
└── .Launcher/ # 启动器数据
|
||
├── state/
|
||
│ └── oobe-state.json
|
||
├── update/
|
||
│ ├── incoming/ # 更新缓存
|
||
│ │ ├── deployment.lock # 部署锁
|
||
│ │ ├── plonds-filemap.json # 文件清单
|
||
│ │ ├── plonds-filemap.sig # RSA 签名
|
||
│ │ └── objects/ # 对象文件
|
||
│ ├── public-key.pem # 公钥
|
||
│ └── snapshots/ # 更新快照
|
||
│ └── snapshot-{id}.json
|
||
└── logs/
|
||
```
|
||
|
||
### 版本标记文件
|
||
|
||
| 标记文件 | 含义 | 创建者 | 删除时机 |
|
||
|---------|------|--------|---------|
|
||
| `.current` | 当前使用版本 | Host UpdateInstallGateway | 切换到新版本时 |
|
||
| `.partial` | 下载未完成 | Host UpdateInstallGateway | 安装成功或失败清理时 |
|
||
| `.destroy` | 待删除版本 | Host UpdateInstallGateway | Launcher 下次启动清理 |
|
||
|
||
## 更新流程
|
||
|
||
### 完整更新流程
|
||
|
||
```
|
||
1. Host 后台检查更新
|
||
UpdateOrchestrator.CheckAsync()
|
||
- 调用 manifest provider (PLONDS/GitHub)
|
||
- 根据更新频道过滤版本
|
||
- 对比当前版本和最新版本
|
||
↓
|
||
2. 发现新版本
|
||
用户收到通知
|
||
↓
|
||
3. Host 下载更新
|
||
UpdateOrchestrator.DownloadAsync()
|
||
- 下载 plonds-filemap.json
|
||
- 下载 plonds-filemap.sig
|
||
- 下载对象文件
|
||
- 保存到 .Launcher/update/incoming/
|
||
↓
|
||
4. 写入 deployment.lock
|
||
{
|
||
"TargetVersion": "1.0.2",
|
||
"FromVersion": "1.0.1",
|
||
"FileMapPath": "...",
|
||
"SignaturePath": "...",
|
||
"ArchivePath": "..."
|
||
}
|
||
↓
|
||
5. Host 调用 UpdateInstallGateway
|
||
UpdateInstallGateway.InstallAsync()
|
||
- 验证签名 (UpdateSignatureVerifier)
|
||
- 创建目标目录 app-1.0.2/
|
||
- 标记 .partial
|
||
- 保存快照到 snapshots/
|
||
↓
|
||
6. 应用文件操作
|
||
PlondsUpdateApplier.ApplyFileMap()
|
||
For each file in filemap:
|
||
- action="replace" → 从对象解压
|
||
- action="reuse" → 从旧版本复制
|
||
- action="delete" → 跳过
|
||
↓
|
||
7. 验证所有文件哈希
|
||
For each file:
|
||
计算 SHA256
|
||
与 filemap 对比
|
||
↓
|
||
8. 激活新版本
|
||
DeploymentActivator.ActivateDeployment()
|
||
- 移除 app-1.0.2/.partial
|
||
- 添加 app-1.0.2/.current
|
||
- 移除 app-1.0.1/.current
|
||
- 添加 app-1.0.1/.destroy
|
||
↓
|
||
9. 更新快照状态 = "applied"
|
||
↓
|
||
10. 清理 incoming 缓存
|
||
IncomingArtifactsCleaner.Clean()
|
||
↓
|
||
11. 用户重启应用
|
||
Launcher 启动 app-1.0.2/
|
||
↓
|
||
12. Launcher 清理 .destroy 目录
|
||
删除 app-1.0.1/
|
||
```
|
||
|
||
### 增量包结构
|
||
|
||
#### GitHub Release Assets
|
||
|
||
```
|
||
LanMountainDesktop-v1.0.2/
|
||
├── LanMountainDesktop-Setup-1.0.2-x64.exe # 完整安装包
|
||
├── releases.win.json # VeloPack 清单
|
||
├── LanMountainDesktop-1.0.2-win-x64.nupkg # VeloPack 包
|
||
├── RELEASES # VeloPack 元数据
|
||
└── (legacy) plonds-filemap-1.0.2.json # 旧格式(可选)
|
||
```
|
||
|
||
#### plonds-filemap.json 格式
|
||
|
||
```json
|
||
{
|
||
"FromVersion": "1.0.1",
|
||
"ToVersion": "1.0.2",
|
||
"GeneratedAt": "2026-06-08T10:00:00Z",
|
||
"Files": [
|
||
{
|
||
"Path": "LanMountainDesktop.exe",
|
||
"Action": "replace",
|
||
"Sha256": "abc123...",
|
||
"Size": 1024000,
|
||
"ObjectPath": "objects/abc123"
|
||
},
|
||
{
|
||
"Path": "LanMountainDesktop.dll",
|
||
"Action": "reuse",
|
||
"Sha256": "def456...",
|
||
"Size": 512000
|
||
},
|
||
{
|
||
"Path": "OldPlugin.dll",
|
||
"Action": "delete"
|
||
}
|
||
]
|
||
}
|
||
```
|
||
|
||
### 文件操作类型
|
||
|
||
| Action | 说明 | 处理方式 |
|
||
|--------|------|----------|
|
||
| `replace` | 新增或替换文件 | 从 objects/ 解压 |
|
||
| `reuse` | 文件未变更 | 从旧版本目录复制 |
|
||
| `delete` | 删除文件 | 不操作(新版本中不存在) |
|
||
|
||
## 原子化保证
|
||
|
||
### 失败场景处理
|
||
|
||
| 失败场景 | 检测机制 | 恢复方式 |
|
||
|---------|---------|---------|
|
||
| **签名验证失败** | RSA 验证 | 拒绝安装,保留旧版本 |
|
||
| **文件哈希不匹配** | SHA256 校验 | 自动回滚到旧版本 |
|
||
| **磁盘空间不足** | 写入失败 | 自动回滚到旧版本 |
|
||
| **进程崩溃** | .partial 标记 | Launcher 启动时清理 |
|
||
| **断电/强制关机** | .partial 标记 | Launcher 启动时清理 |
|
||
|
||
### 回滚机制
|
||
|
||
```csharp
|
||
private void TryRollbackOnFailure(SnapshotMetadata snapshot)
|
||
{
|
||
try
|
||
{
|
||
// 1. 删除未完成的新版本目录
|
||
if (Directory.Exists(snapshot.TargetDirectory))
|
||
{
|
||
Directory.Delete(snapshot.TargetDirectory, true);
|
||
}
|
||
|
||
// 2. 移除旧版本的 .destroy 标记
|
||
var destroyMarker = Path.Combine(
|
||
snapshot.SourceDirectory,
|
||
".destroy"
|
||
);
|
||
if (File.Exists(destroyMarker))
|
||
{
|
||
File.Delete(destroyMarker);
|
||
}
|
||
|
||
// 3. 确保旧版本有 .current 标记
|
||
var currentMarker = Path.Combine(
|
||
snapshot.SourceDirectory,
|
||
".current"
|
||
);
|
||
if (!File.Exists(currentMarker))
|
||
{
|
||
File.WriteAllText(currentMarker, string.Empty);
|
||
}
|
||
|
||
// 4. 更新快照状态
|
||
snapshot.Status = "rolled_back";
|
||
SaveSnapshot(snapshotPath, snapshot);
|
||
}
|
||
catch (Exception ex)
|
||
{
|
||
_logger.LogError(ex, "Rollback failed");
|
||
// 记录错误但不抛出
|
||
}
|
||
}
|
||
```
|
||
|
||
## 版本管理
|
||
|
||
### 版本选择算法
|
||
|
||
Launcher 启动时的版本选择逻辑:
|
||
|
||
```csharp
|
||
public string? FindBestVersion()
|
||
{
|
||
var appDirs = Directory.GetDirectories(
|
||
_installRoot,
|
||
"app-*"
|
||
);
|
||
|
||
// 1. 过滤掉标记为 .partial 或 .destroy 的版本
|
||
var validDirs = appDirs.Where(dir =>
|
||
!File.Exists(Path.Combine(dir, ".partial")) &&
|
||
!File.Exists(Path.Combine(dir, ".destroy"))
|
||
);
|
||
|
||
// 2. 优先选择带 .current 标记的版本
|
||
var currentDir = validDirs.FirstOrDefault(dir =>
|
||
File.Exists(Path.Combine(dir, ".current"))
|
||
);
|
||
if (currentDir != null)
|
||
return currentDir;
|
||
|
||
// 3. 否则选择版本号最高的
|
||
var sortedDirs = validDirs
|
||
.Select(dir => new {
|
||
Path = dir,
|
||
Version = ParseVersion(dir)
|
||
})
|
||
.Where(x => x.Version != null)
|
||
.OrderByDescending(x => x.Version)
|
||
.ToList();
|
||
|
||
return sortedDirs.FirstOrDefault()?.Path;
|
||
}
|
||
```
|
||
|
||
### 版本回退
|
||
|
||
用户可以通过设置页手动回退到上一版本:
|
||
|
||
```
|
||
设置 → 更新 → 版本历史 → 回滚
|
||
```
|
||
|
||
**回退流程**:
|
||
|
||
```
|
||
1. Host 调用 UpdateRollbackGateway
|
||
↓
|
||
2. 读取最新快照 (snapshots/)
|
||
↓
|
||
3. 获取 SourceDirectory (旧版本)
|
||
↓
|
||
4. 激活旧版本
|
||
- 移除当前版本的 .current
|
||
- 添加 .current 到旧版本
|
||
- 移除旧版本的 .destroy
|
||
- 添加 .destroy 到当前版本
|
||
↓
|
||
5. 更新快照状态 = "manual_rollback"
|
||
↓
|
||
6. 提示用户重启应用
|
||
```
|
||
|
||
## 安全机制
|
||
|
||
### RSA 签名
|
||
|
||
#### 签名生成(CI 环境)
|
||
|
||
```powershell
|
||
# scripts/Sign-FileMap.ps1
|
||
$privateKeyPem = Get-Content -Path $PrivateKeyPath -Raw
|
||
$rsa = [System.Security.Cryptography.RSA]::Create()
|
||
$rsa.ImportFromPem($privateKeyPem)
|
||
|
||
$jsonBytes = [System.IO.File]::ReadAllBytes($FilesJsonPath)
|
||
$signature = $rsa.SignData(
|
||
$jsonBytes,
|
||
[System.Security.Cryptography.HashAlgorithmName]::SHA256,
|
||
[System.Security.Cryptography.RSASignaturePadding]::Pkcs1
|
||
)
|
||
|
||
$signatureBase64 = [Convert]::ToBase64String($signature)
|
||
Set-Content -Path "$FilesJsonPath.sig" -Value $signatureBase64
|
||
```
|
||
|
||
#### 签名验证(Launcher)
|
||
|
||
```csharp
|
||
private (bool Success, string Message) VerifySignature(
|
||
string fileMapPath,
|
||
string signaturePath)
|
||
{
|
||
// 1. 读取公钥
|
||
var publicKeyPath = Path.Combine(
|
||
_launcherRoot,
|
||
"update",
|
||
"public-key.pem"
|
||
);
|
||
using var rsa = RSA.Create();
|
||
rsa.ImportFromPem(File.ReadAllText(publicKeyPath));
|
||
|
||
// 2. 读取签名
|
||
var signatureBase64 = File.ReadAllText(signaturePath).Trim();
|
||
var signature = Convert.FromBase64String(signatureBase64);
|
||
|
||
// 3. 验证
|
||
var jsonBytes = File.ReadAllBytes(fileMapPath);
|
||
var isValid = rsa.VerifyData(
|
||
jsonBytes,
|
||
signature,
|
||
HashAlgorithmName.SHA256,
|
||
RSASignaturePadding.Pkcs1
|
||
);
|
||
|
||
return isValid
|
||
? (true, "ok")
|
||
: (false, "Signature verification failed");
|
||
}
|
||
```
|
||
|
||
### 文件完整性验证
|
||
|
||
```csharp
|
||
// 验证所有文件的 SHA256
|
||
foreach (var file in fileMap.Files)
|
||
{
|
||
if (file.Action == "delete")
|
||
continue;
|
||
|
||
var fullPath = Path.Combine(targetDeployment, file.Path);
|
||
var actualHash = ComputeSha256Hex(fullPath);
|
||
|
||
if (!string.Equals(
|
||
actualHash,
|
||
file.Sha256,
|
||
StringComparison.OrdinalIgnoreCase))
|
||
{
|
||
throw new InvalidOperationException(
|
||
$"File hash mismatch for '{file.Path}'."
|
||
);
|
||
}
|
||
}
|
||
```
|
||
|
||
### 路径遍历防护
|
||
|
||
```csharp
|
||
private static void EnsurePathWithinRoot(
|
||
string targetPath,
|
||
string rootPath)
|
||
{
|
||
var fullTarget = Path.GetFullPath(targetPath);
|
||
var fullRoot = Path.GetFullPath(rootPath);
|
||
|
||
if (!fullTarget.StartsWith(
|
||
fullRoot,
|
||
StringComparison.OrdinalIgnoreCase))
|
||
{
|
||
throw new InvalidOperationException(
|
||
$"Path traversal detected: {targetPath}"
|
||
);
|
||
}
|
||
}
|
||
```
|
||
|
||
## 更新频道
|
||
|
||
### 频道类型
|
||
|
||
| 频道 | 说明 | GitHub Release 过滤 |
|
||
|------|------|---------------------|
|
||
| **Stable** | 正式版,推荐大多数用户使用 | `prerelease=false` |
|
||
| **Preview** | 预览版,包含最新功能和 Bug 修复 | 所有版本(包括 `prerelease=true`) |
|
||
|
||
### 切换频道
|
||
|
||
```
|
||
设置 → 更新 → 更新频道 → 选择频道
|
||
```
|
||
|
||
切换后立即检查更新。
|
||
|
||
## VeloPack 集成
|
||
|
||
### 当前状态
|
||
|
||
- ✅ CI 管道已生成 VeloPack 原生资产(`releases.win.json`, `*.nupkg`, `RELEASES`)
|
||
- ✅ Host 拥有更新检查/下载/应用/回滚编排
|
||
- ✅ Launcher 仅负责版本选择和启动
|
||
- ⚠️ 旧的 `files.json` + `update.zip` 生成作为禁用的回退路径保留在 CI 中
|
||
|
||
### 未来迁移
|
||
|
||
计划完全迁移到 VeloPack:
|
||
1. 移除旧格式支持
|
||
2. 简化 CI 管道
|
||
3. 利用 VeloPack 的增量更新算法
|
||
|
||
## 相关文档
|
||
|
||
- [打包与构建](../05-更新与发布/03-打包与构建.md) - 构建流程
|
||
- [CI/CD 配置](../05-更新与发布/04-CICD配置.md) - 自动化构建
|
||
- [启动器系统](02-启动器系统.md) - 启动器详细设计
|