Files
LanMountainDesktop/docs/05-更新与发布/01-更新系统架构.md
2026-06-08 03:54:33 +08:00

14 KiB
Raw Blame History

更新系统架构

本文档描述阑山桌面的更新系统设计,包括增量更新、原子化安装、版本管理和回滚机制。

更新系统概览

核心特性

  • 增量更新 - 只下载变更文件,节省带宽
  • 原子化更新 - 保证完整性,失败自动回滚
  • 多版本共存 - 支持多版本并存和快速切换
  • 签名验证 - 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

  1. 移除旧格式支持
  2. 简化 CI 管道
  3. 利用 VeloPack 的增量更新算法

相关文档