Files
LanMountainDesktop/docs/LAUNCHER.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

13 KiB

Launcher 架构文档

LanMountainDesktop.Launcher - 应用启动器和版本管理系统

目录

概述

Launcher 是 LanMountainDesktop 的唯一入口点,负责:

  • 首次体验引导 (OOBE)
  • 启动动画 (Splash Screen)
  • 多版本管理和选择
  • 应用更新 (增量更新、原子化更新)
  • 插件安装和升级
  • 版本回退

设计理念: 参考 ClassIsland 项目,实现原子化的多版本管理和随时版本回退能力。

职责范围

1. OOBE (Out-of-Box Experience)

  • 首次启动引导
  • 欢迎页面
  • 初始设置向导

2. Splash Screen

  • 启动动画
  • 加载进度显示
  • 品牌展示

3. 版本管理

  • 多版本并存 (app-{version}/ 目录)
  • 版本选择算法
  • 版本标记系统 (.current, .partial, .destroy)
  • 旧版本自动清理

4. 应用更新

  • GitHub Release API 集成
  • 更新频道管理 (Stable/Preview)
  • 增量更新下载
  • 原子化更新应用
  • 签名验证
  • 版本回退

5. 插件管理

  • 插件安装 (.laapp 包)
  • 插件更新检查
  • 插件升级队列处理

架构设计

目录结构

安装后的目录结构:

C:\Program Files\LanMountainDesktop\
├── LanMountainDesktop.Launcher.exe  ← 唯一入口
├── app-1.0.0/                        ← 版本目录
│   ├── .current                      ← 当前版本标记
│   ├── LanMountainDesktop.exe
│   ├── LanMountainDesktop.dll
│   └── ... (所有依赖)
├── app-1.0.1/                        ← 新版本
│   ├── .partial                      ← 下载中标记
│   └── ...
├── app-0.9.9/                        ← 旧版本
│   ├── .destroy                      ← 待删除标记
│   └── ...
└── .launcher/                        ← Launcher 数据目录
    ├── state/
    │   └── first_run_completed       ← OOBE 完成标记
    ├── update/
    │   ├── incoming/                 ← 更新缓存
    │   │   ├── files.json
    │   │   ├── files.json.sig
    │   │   └── update.zip
    │   └── public-key.pem            ← RSA 公钥
    └── snapshots/                    ← 更新快照
        └── {snapshot-id}.json

版本标记文件

文件名 作用 创建时机 删除时机
.current 标记当前使用的版本 更新完成后 新版本激活时
.partial 标记下载未完成的版本 开始下载时 下载完成验证通过后
.destroy 标记待删除的旧版本 新版本激活时 目录删除后

核心服务

DeploymentLocator

职责: 扫描和定位版本目录,选择最佳版本

关键方法:

// 查找当前部署目录
string? FindCurrentDeploymentDirectory()

// 解析主程序可执行文件路径
string? ResolveHostExecutablePath()

// 获取当前版本号
string GetCurrentVersion()

// 构建下一个部署目录路径
string BuildNextDeploymentDirectory(string targetVersion)

// 清理标记为 .destroy 的目录
void CleanupDestroyedDeployments()

版本选择算法:

  1. 扫描所有 app-* 目录
  2. 过滤掉带 .destroy.partial 标记的目录
  3. 优先选择带 .current 标记的版本
  4. 如果没有 .current,选择版本号最高的

UpdateCheckService

职责: 检查 GitHub Release 更新

关键方法:

// 检查更新
Task<UpdateCheckResult> CheckForUpdateAsync(
    string currentVersion,
    UpdateChannel channel,
    CancellationToken cancellationToken = default)

更新频道:

  • Stable - 只检查 prerelease=false 的版本
  • Preview - 检查所有版本 (包括 prerelease=true)

UpdateEngineService

职责: 下载、验证、应用更新

关键方法:

// 检查待处理的更新
LauncherResult CheckPendingUpdate()

// 下载更新
Task<LauncherResult> DownloadAsync(
    string manifestUrl,
    string signatureUrl,
    string archiveUrl,
    CancellationToken cancellationToken)

// 应用待处理的更新
LauncherResult ApplyPendingUpdate()

// 回退到上一个版本
LauncherResult RollbackLatest()

// 清理待删除的部署
void CleanupDestroyedDeployments()

LauncherFlowCoordinator

职责: 协调完整的启动流程

启动流程:

  1. 清理待删除的旧版本
  2. 检查是否首次运行,显示 OOBE
  3. 显示 Splash 窗口
  4. 应用待处理的更新
  5. 处理插件升级队列
  6. 启动主程序
  7. 关闭 Splash 窗口

OobeStateService

职责: 管理首次运行状态

关键方法:

// 检查是否首次运行
bool IsFirstRun()

// 标记 OOBE 已完成
void MarkCompleted()

PluginInstallerService

职责: 处理插件安装

关键方法:

// 安装插件包
Task<PluginInstallResult> InstallAsync(
    string packagePath,
    string targetDirectory,
    CancellationToken cancellationToken = default)

PluginUpgradeQueueService

职责: 批量处理插件升级队列

关键方法:

// 应用待处理的插件升级
LauncherResult ApplyPendingUpgrades(string pluginsDirectory)

版本管理

版本选择算法详解

public string? FindCurrentDeploymentDirectory()
{
    var candidates = Directory.GetDirectories(rootDir, "app-*");
    
    // 1. 过滤无效版本
    var validCandidates = candidates
        .Where(path => 
            !File.Exists(Path.Combine(path, ".destroy")) &&
            !File.Exists(Path.Combine(path, ".partial")))
        .ToList();
    
    // 2. 优先选择带 .current 标记的
    var withMarkers = validCandidates
        .Where(path => File.Exists(Path.Combine(path, ".current")))
        .OrderByDescending(path => ParseVersion(path))
        .FirstOrDefault();
    
    if (withMarkers != null)
        return withMarkers;
    
    // 3. 选择版本号最高的
    return validCandidates
        .OrderByDescending(path => ParseVersion(path))
        .FirstOrDefault();
}

版本激活流程

private void ActivateDeployment(string fromDeployment, string toDeployment)
{
    // 1. 在新版本添加 .current 标记
    File.WriteAllText(Path.Combine(toDeployment, ".current"), string.Empty);
    
    // 2. 移除旧版本的 .current 标记
    var fromCurrent = Path.Combine(fromDeployment, ".current");
    if (File.Exists(fromCurrent))
        File.Delete(fromCurrent);
    
    // 3. 标记旧版本为待删除
    File.WriteAllText(Path.Combine(fromDeployment, ".destroy"), string.Empty);
    
    // 4. 移除新版本的 .partial 标记 (如果有)
    var toPartial = Path.Combine(toDeployment, ".partial");
    if (File.Exists(toPartial))
        File.Delete(toPartial);
}

版本清理流程

public void CleanupDestroyedDeployments()
{
    var destroyedDirs = Directory.GetDirectories(rootDir)
        .Where(x => File.Exists(Path.Combine(x, ".destroy")));
    
    foreach (var dir in destroyedDirs)
    {
        try
        {
            Directory.Delete(dir, recursive: true);
        }
        catch
        {
            // 忽略删除失败 (可能文件被占用)
            // 下次启动时再试
        }
    }
}

启动流程

完整启动流程图

用户启动 Launcher.exe
    ↓
清理旧版本 (.destroy 目录)
    ↓
首次运行? ──Yes→ 显示 OOBE 窗口
    ↓ No
显示 Splash 窗口
    ↓
检查待处理的更新
    ↓
有更新? ──Yes→ 应用更新 (原子化)
    ↓ No
处理插件升级队列
    ↓
选择最佳版本 (DeploymentLocator)
    ↓
启动主程序 (Process.Start)
    ↓
关闭 Splash 窗口
    ↓
Launcher 退出

代码流程

Program.cs:

static async Task<int> Main(string[] args)
{
    var commandContext = CommandContext.FromArgs(args);
    
    // 处理 CLI 命令
    if (commandContext.Command != "launch")
        return await Commands.RunCliCommandAsync(commandContext);
    
    // 启动 Avalonia 应用
    LauncherRuntimeContext.Current = commandContext;
    BuildAvaloniaApp().StartWithClassicDesktopLifetime(args);
    return Environment.ExitCode;
}

App.axaml.cs:

public override void OnFrameworkInitializationCompleted()
{
    var appRoot = Commands.ResolveAppRoot(context);
    var deploymentLocator = new DeploymentLocator(appRoot);
    var updateCheckService = new UpdateCheckService("owner", "repo");
    
    var coordinator = new LauncherFlowCoordinator(
        context,
        deploymentLocator,
        new OobeStateService(appRoot),
        new UpdateEngineService(deploymentLocator),
        updateCheckService,
        new PluginInstallerService());
    
    _ = RunCoordinatorAsync(desktop, coordinator);
}

LauncherFlowCoordinator.RunAsync():

public async Task<LauncherResult> RunAsync()
{
    // 1. 清理旧版本
    _deploymentLocator.CleanupDestroyedDeployments();
    
    // 2. OOBE
    if (_oobeStateService.IsFirstRun())
    {
        foreach (var step in _oobeSteps)
            await step.RunAsync(CancellationToken.None);
    }
    
    // 3. Splash
    var splashWindow = await Dispatcher.UIThread.InvokeAsync(() =>
    {
        var window = new SplashWindow();
        window.Show();
        return window;
    });
    
    try
    {
        // 4. 应用更新
        var updateResult = _updateEngine.ApplyPendingUpdate();
        if (!updateResult.Success)
            return updateResult;
        
        // 5. 插件升级
        var pluginsDir = Path.Combine(_deploymentLocator.GetAppRoot(), "plugins");
        var queueResult = new PluginUpgradeQueueService(_pluginInstallerService)
            .ApplyPendingUpgrades(pluginsDir);
        if (!queueResult.Success)
            return queueResult;
        
        // 6. 启动主程序
        var hostResult = LaunchHost();
        if (!hostResult.Success)
            return hostResult;
        
        return new LauncherResult { Success = true };
    }
    finally
    {
        await Dispatcher.UIThread.InvokeAsync(() => splashWindow.Close());
    }
}

命令行接口

launch - 启动应用

LanMountainDesktop.Launcher.exe launch

启动完整流程: OOBE → Splash → 更新 → 插件 → 主程序

update check - 检查更新

LanMountainDesktop.Launcher.exe update check

检查 GitHub Release 是否有新版本。

update download - 下载更新

LanMountainDesktop.Launcher.exe update download --version 1.0.1

下载指定版本的更新包。

update apply - 应用更新

LanMountainDesktop.Launcher.exe update apply

应用已下载的更新 (原子化操作)。

update rollback - 版本回退

LanMountainDesktop.Launcher.exe update rollback

回退到上一个有效版本。

plugin install - 安装插件

LanMountainDesktop.Launcher.exe plugin install <path-to-plugin.laapp>

安装 .laapp 插件包。

开发指南

本地调试

直接运行 Launcher:

dotnet run --project LanMountainDesktop.Launcher/LanMountainDesktop.Launcher.csproj -- launch

调试特定命令:

# 检查更新
dotnet run --project LanMountainDesktop.Launcher/LanMountainDesktop.Launcher.csproj -- update check

# 版本回退
dotnet run --project LanMountainDesktop.Launcher/LanMountainDesktop.Launcher.csproj -- update rollback

模拟多版本环境

# 1. 发布主程序
dotnet publish LanMountainDesktop/LanMountainDesktop.csproj -c Debug -o ./test-deploy/app-1.0.0

# 2. 创建 .current 标记
New-Item -ItemType File -Path ./test-deploy/app-1.0.0/.current

# 3. 复制 Launcher 到根目录
Copy-Item LanMountainDesktop.Launcher/bin/Debug/net10.0/* ./test-deploy/

# 4. 运行 Launcher
./test-deploy/LanMountainDesktop.Launcher.exe launch

测试更新流程

# 1. 创建两个版本
dotnet publish LanMountainDesktop/LanMountainDesktop.csproj -o ./test-deploy/app-1.0.0
dotnet publish LanMountainDesktop/LanMountainDesktop.csproj -o ./test-deploy/app-1.0.1

# 2. 生成增量包
pwsh ./scripts/Generate-DeltaPackage.ps1 `
  -PreviousVersion "1.0.0" `
  -CurrentVersion "1.0.1" `
  -PreviousDir "./test-deploy/app-1.0.0" `
  -CurrentDir "./test-deploy/app-1.0.1" `
  -OutputDir "./test-deploy/.launcher/update/incoming"

# 3. 测试应用更新
./test-deploy/LanMountainDesktop.Launcher.exe update apply

添加新的 OOBE 步骤

  1. 实现 IOobeStep 接口:
public class MyOobeStep : IOobeStep
{
    public async Task RunAsync(CancellationToken cancellationToken)
    {
        // 显示 OOBE 窗口
        // 等待用户完成
    }
}
  1. LauncherFlowCoordinator 中注册:
_oobeSteps = [
    new WelcomeOobeStep(_oobeStateService),
    new MyOobeStep()  // 添加新步骤
];

自定义更新源

修改 App.axaml.cs 中的 GitHub 仓库信息:

var updateCheckService = new UpdateCheckService(
    "YourOrg",      // GitHub 组织/用户名
    "YourRepo"      // 仓库名
);

相关文档