Files
LanMountainDesktop/docs/UPDATE_SYSTEM.md
lincube 4cb52e56c7 Launcher (#4)
* 激进的更新

* 试试

* fix.可爱的我一直在修CI(

* fix.启动器一定要能够启动

* feat.尝试弄了AOT的启动器。

* fix.修CI,好像是因为Linux那边有个问题,反正修就对了。

* fix.ci难修,为什么liunx跑不起来呢?

* Update build.yml

* Update LanMountainDesktop.csproj

* changed.调整了启动逻辑,优化了更新页面。

* changed.优化了更新体验

* feat.依旧试增量更新这一块,看看velopack

* fix.我们试验性地修复了启动器无法正常启动的问题,原因可能是这个画面没有启动,就GUI没显示。然后还把编译问题修了一下。

* fix.继续修ci,ci怎么天天炸

* changed.velopack,试试rust

* fix.修ci,修融合桌面,修启动器

* fix.GitHub Action工作流怎么天天出问题

* feat.引入velopack,不好,是rust(至少内存很安全了。

* chore: migrate release pipeline to signed filemap and wire rainyun s3

* fix: make optional s3 upload step workflow-parse safe

* fix: make delta pack generation robust for empty diffs and linux paths

* chore: rotate launcher update public key for pdc signing

* fix: restore stable launcher update public key

* fix: sync launcher public key with update signing secret

* fix: normalize PEM line endings in signing key validation

* fix: rotate launcher public key to match ci signing secret

* fix: compare signing keys by SPKI instead of PEM text

* refactor update backend to host-managed PDC pipeline

* fix release workflow env key collisions

* relax publish-pdc precheck to require S3 only

* set GH_TOKEN for PDCC installer step

* ci: add local pdc mock fallback for release publish

* ci: fix pdc mock process log redirection

* ci: fallback pdcc signing key to update private key

* ci: ensure pdcc signing passphrase env is always set

* ci: create pdcc publish root before invoking client

* ci: set pdcc version variable from release version

* ci: decouple pdcc installer version from publish config version

* ci: package pdcc subchannels with generated filemap and changelog

* ci: make local pdc mock diff return empty for fast fallback

* ci: fix pdcc variable mapping and pdc signing prechecks

* Update App.axaml.cs

* ci: wire aws cli credentials for rainyun s3

* ci: pin pdcc client version separately from app version

* ci: harden local pdc mock transport handling

* ci: publish pdcc subchannels in one pass

* ci: add pdcc publish heartbeat and timeout

* ci: fix pdcc publish workdir bootstrap

* feat.Penguin Logistics Online Network Distribution System

* ci: fix plonds s3 probe and signing fallback

* ci: validate signing key and quiet missing baselines

* ci: relax aws checksum mode for rainyun s3

* ci: avoid multipart uploads to rainyun s3

* ci: handle empty plonds baselines safely

* ci.plonds

* Rebuild release pipeline around PLONDS and DDSS

* Fix Windows installer script path in release workflow
2026-04-21 20:59:52 +08:00

452 lines
12 KiB
Markdown
Raw Blame History

This file contains invisible Unicode characters
This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 更新系统文档
> LanMountainDesktop 增量更新和版本管理系统
## 目录
- [概述](#概述)
- [更新流程](#更新流程)
- [增量更新](#增量更新)
- [原子化更新](#原子化更新)
- [版本回退](#版本回退)
- [CI/CD 集成](#cicd-集成)
- [安全机制](#安全机制)
## 概述
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 格式
```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` 脚本:
```powershell
./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. **自动回滚** - 失败时恢复到旧版本
### 更新步骤详解
```csharp
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);
}
}
```
### 失败回滚
```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);
}
catch
{
// 记录错误但不抛出
}
}
```
## 版本回退
### 手动回退
```bash
LanMountainDesktop.Launcher.exe update rollback
```
### 回退流程
```csharp
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}");
}
```
### 快照格式
```json
{
"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 关键步骤:**
```yaml
- 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):**
```powershell
# 读取私钥
$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):**
```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 (!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}'.");
}
}
```
### 路径遍历防护
```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}");
}
}
```
## 相关文档
- [Launcher 架构文档](LAUNCHER.md)
- [构建和部署指南](BUILD_AND_DEPLOY.md)
- [故障排除指南](TROUBLESHOOTING.md)
## VeloPack Packaging (Current)
- Release pipeline now produces VeloPack native assets (
eleases.win.json, *.nupkg, RELEASES).
- Launcher remains the installer and rollback authority; only package generation moved to VeloPack.
- Legacy iles.json + update.zip generation remains available only as a disabled fallback path in CI.