mirror of
https://github.com/wwiinnddyy/LanMountainDesktop.git
synced 2026-06-20 23:54:26 +08:00
14 KiB
14 KiB
更新系统架构
本文档描述阑山桌面的更新系统设计,包括增量更新、原子化安装、版本管理和回滚机制。
更新系统概览
核心特性
- ✅ 增量更新 - 只下载变更文件,节省带宽
- ✅ 原子化更新 - 保证完整性,失败自动回滚
- ✅ 多版本共存 - 支持多版本并存和快速切换
- ✅ 签名验证 - 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 格式
{
"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 启动时清理 |
回滚机制
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 启动时的版本选择逻辑:
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 环境)
# 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)
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");
}
文件完整性验证
// 验证所有文件的 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}'."
);
}
}
路径遍历防护
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:
- 移除旧格式支持
- 简化 CI 管道
- 利用 VeloPack 的增量更新算法