Files
LanMountainDesktop/docs/UPDATE_SYSTEM.md
2026-04-16 01:59:21 +08:00

11 KiB

更新系统文档

LanMountainDesktop 增量更新和版本管理系统

目录

概述

LanMountainDesktop 使用基于 GitHub Release 的增量更新系统,支持:

  • 增量更新 (只下载变更文件)
  • 原子化更新 (保证完整性)
  • 签名验证 (RSA)
  • 版本回退
  • 更新频道 (Stable/Preview)
  • 静默更新 (后台下载)

更新流程

完整更新流程图

Launcher 启动
    ↓
UpdateCheckService.CheckForUpdateAsync()
    ├─ 调用 GitHub Release API
    ├─ 根据更新频道过滤版本
    └─ 对比当前版本和最新版本
    ↓
有新版本? ──No→ 继续启动
    ↓ Yes
UpdateEngineService.DownloadAsync()
    ├─ 下载 files-{version}.json
    ├─ 下载 files-{version}.json.sig
    └─ 下载 delta-{old}-to-{new}.zip (或完整包)
    ↓
保存到 .launcher/update/incoming/
    ↓
下次启动时
    ↓
UpdateEngineService.ApplyPendingUpdate()
    ├─ 验证签名
    ├─ 创建 app-{new}/ 目录
    ├─ 标记 .partial
    ├─ 解压增量包
    ├─ 从旧版本复用未变更文件
    ├─ 验证所有文件 SHA256
    ├─ 删除 .partial
    ├─ 添加 .current 到新版本
    ├─ 标记旧版本 .destroy
    └─ 保存更新快照
    ↓
启动新版本
    ↓
清理旧版本 (.destroy)

更新频道

频道 说明 GitHub Release 过滤
Stable 正式版 prerelease=false
Preview 预览版 所有版本 (包括 prerelease=true)

用户可以在设置中切换更新频道。

增量更新

增量包结构

GitHub Release Assets:

LanMountainDesktop-v1.0.1/
├── LanMountainDesktop-Setup-1.0.1-x64.exe  # 完整安装包
├── app-1.0.1.zip                            # 完整应用包
├── delta-1.0.0-to-1.0.1.zip                # 增量包
├── files-1.0.1.json                         # 文件清单
└── files-1.0.1.json.sig                     # RSA 签名

files.json 格式

{
  "FromVersion": "1.0.0",
  "ToVersion": "1.0.1",
  "GeneratedAt": "2025-01-01T00:00:00Z",
  "Files": [
    {
      "Path": "LanMountainDesktop.exe",
      "Action": "replace",
      "Sha256": "abc123...",
      "Size": 1024000,
      "ArchivePath": "LanMountainDesktop.exe"
    },
    {
      "Path": "LanMountainDesktop.dll",
      "Action": "reuse",
      "Sha256": "def456...",
      "Size": 512000
    },
    {
      "Path": "OldFile.dll",
      "Action": "delete"
    }
  ]
}

文件操作类型

Action 说明 处理方式
add 新增文件 从增量包解压
replace 替换文件 从增量包解压
reuse 复用文件 从旧版本复制
delete 删除文件 不操作 (新版本中不存在)

增量包生成

使用 Generate-DeltaPackage.ps1 脚本:

./scripts/Generate-DeltaPackage.ps1 `
  -PreviousVersion "1.0.0" `
  -CurrentVersion "1.0.1" `
  -PreviousDir "./publish/app-1.0.0" `
  -CurrentDir "./publish/app-1.0.1" `
  -OutputDir "./delta-output"

生成过程:

  1. 扫描两个版本的所有文件
  2. 计算每个文件的 SHA256
  3. 对比哈希值,识别变更
  4. 只打包变更的文件到 delta.zip
  5. 生成 files.json 清单

优势:

  • 大幅减少下载大小 (通常只有 10-30% 的完整包大小)
  • 加快更新速度
  • 节省带宽

原子化更新

原子化保证

更新过程中的任何失败都会触发自动回滚,确保应用始终处于可用状态。

关键机制:

  1. .partial 标记 - 更新过程中保持此标记
  2. 旧版本保留 - 直到新版本验证通过
  3. SHA256 验证 - 确保所有文件完整性
  4. 快照记录 - 记录更新前后状态
  5. 自动回滚 - 失败时恢复到旧版本

更新步骤详解

public LauncherResult ApplyPendingUpdate()
{
    // 1. 验证签名
    var verifyResult = VerifySignature(fileMapPath, signaturePath);
    if (!verifyResult.Success)
        return Failed("signature_failed");
    
    // 2. 创建新版本目录
    var targetDeployment = _deploymentLocator.BuildNextDeploymentDirectory(targetVersion);
    Directory.CreateDirectory(targetDeployment);
    
    // 3. 标记为未完成
    File.WriteAllText(Path.Combine(targetDeployment, ".partial"), string.Empty);
    
    // 4. 保存快照
    var snapshot = new SnapshotMetadata { ... };
    SaveSnapshot(snapshotPath, snapshot);
    
    try
    {
        // 5. 解压增量包
        ZipFile.ExtractToDirectory(archivePath, extractRoot);
        
        // 6. 应用文件操作
        foreach (var file in fileMap.Files)
        {
            ApplyFileEntry(file, currentDeployment, targetDeployment, extractRoot);
        }
        
        // 7. 验证所有文件
        foreach (var file in fileMap.Files)
        {
            var actualHash = ComputeSha256Hex(fullPath);
            if (actualHash != file.Sha256)
                throw new InvalidOperationException("Hash mismatch");
        }
        
        // 8. 激活新版本
        ActivateDeployment(currentDeployment, targetDeployment);
        
        // 9. 更新快照状态
        snapshot.Status = "applied";
        SaveSnapshot(snapshotPath, snapshot);
        
        // 10. 清理
        CleanupIncomingArtifacts();
        
        return Success();
    }
    catch (Exception ex)
    {
        // 自动回滚
        TryRollbackOnFailure(snapshot);
        snapshot.Status = "rolled_back";
        SaveSnapshot(snapshotPath, snapshot);
        return Failed("apply_failed", ex.Message);
    }
}

失败回滚

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);
    }
    catch
    {
        // 记录错误但不抛出
    }
}

版本回退

手动回退

LanMountainDesktop.Launcher.exe update rollback

回退流程

public LauncherResult RollbackLatest()
{
    // 1. 读取最新快照
    var snapshotPath = Directory
        .EnumerateFiles(_snapshotsRoot, "*.json")
        .OrderByDescending(File.GetCreationTimeUtc)
        .FirstOrDefault();
    
    var snapshot = JsonSerializer.Deserialize<SnapshotMetadata>(
        File.ReadAllText(snapshotPath));
    
    // 2. 获取当前部署
    var currentDeployment = _deploymentLocator.FindCurrentDeploymentDirectory();
    
    // 3. 激活旧版本
    ActivateDeployment(currentDeployment, snapshot.SourceDirectory);
    
    // 4. 更新快照状态
    snapshot.Status = "manual_rollback";
    SaveSnapshot(snapshotPath, snapshot);
    
    return Success($"Rolled back to {snapshot.SourceVersion}");
}

快照格式

{
  "SnapshotId": "abc123...",
  "SourceVersion": "1.0.0",
  "TargetVersion": "1.0.1",
  "CreatedAt": "2025-01-01T00:00:00Z",
  "SourceDirectory": "C:\\...\\app-1.0.0",
  "TargetDirectory": "C:\\...\\app-1.0.1",
  "Status": "applied"
}

CI/CD 集成

GitHub Actions 工作流

release.yml 关键步骤:

- name: Restructure for Launcher
  run: |
    # 重组为 app-{version} 结构
    $appDir = "app-${{ needs.prepare.outputs.version }}"
    New-Item -ItemType Directory -Path "publish-launcher/windows-x64"
    Move-Item -Path "publish/windows-x64" -Destination "publish-launcher/windows-x64/$appDir"
    
    # 移动 Launcher 到根目录
    Move-Item -Path "publish-launcher/windows-x64/$appDir/Launcher/*" -Destination "publish-launcher/windows-x64/"
    
    # 创建 .current 标记
    New-Item -ItemType File -Path "publish-launcher/windows-x64/$appDir/.current"

- name: Generate Delta Package
  run: |
    # 生成 files.json
    $files = Get-ChildItem -Path $currentAppPath -Recurse -File
    # ... 计算 SHA256 ...
    
    # 创建完整应用包
    Compress-Archive -Path "$currentAppPath\*" -DestinationPath "app-$version.zip"

- name: Upload Delta Package
  uses: actions/upload-artifact@v4
  with:
    name: delta-package-windows-x64
    path: delta-output/*

增量包生成脚本

scripts/Generate-DeltaPackage.ps1:

  • 对比两个版本目录
  • 识别新增、修改、删除的文件
  • 只打包变更文件
  • 生成 files.json 清单

scripts/Sign-FileMap.ps1:

  • 使用 RSA 私钥签名 files.json
  • 生成 files.json.sig

安全机制

RSA 签名验证

签名生成 (CI):

# 读取私钥
$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
)

# 保存为 Base64
$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 (!NeedsVerification(file))
        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}");
    }
}

相关文档