# 更新系统架构 本文档描述阑山桌面的更新系统设计,包括增量更新、原子化安装、版本管理和回滚机制。 ## 更新系统概览 ### 核心特性 - ✅ **增量更新** - 只下载变更文件,节省带宽 - ✅ **原子化更新** - 保证完整性,失败自动回滚 - ✅ **多版本共存** - 支持多版本并存和快速切换 - ✅ **签名验证** - 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) - 启动器详细设计