Files
LanMountainDesktop/docs/LAUNCHER.md

16 KiB
Raw Blame History

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)

IUpdateEngine / UpdateEngineFacade

职责: 下载、验证、应用更新(实现位于 Update/UpdateEngineFacade.cs,契约 Update/IUpdateEngine.cs

关键方法:

LauncherResult CheckPendingUpdate()
Task<LauncherResult> DownloadAsync(...)
Task<LauncherResult> ApplyPendingUpdateAsync()
LauncherResult RollbackLatest()
void CleanupDestroyedDeployments()
void CleanupIncomingArtifacts()

LauncherOrchestrator / LaunchPipeline

职责: 协调完整的启动流程(Shell/LauncherOrchestrator.cs + Startup/LaunchPipeline.cs

启动阶段 (ILaunchPhase):

  1. CleanupDeploymentsPhase — 清理旧部署
  2. ExistingHostProbePhase — 多实例 / 现有 Host 探测
  3. ApplyPendingUpdatePhase — 应用 pending 更新
  4. OobeGatePhase — OOBE 步骤
  5. LaunchHostPhase — 启动 Host
  6. MonitorStartupPhase — IPC 启动监控

GUI 入口: Shell/LauncherCompositionRoot + Shell/LauncherServiceRegistrationMS DI 轻量装配)

LauncherFlowCoordinator (已移除)

已由 LauncherOrchestrator + LaunchPipeline 替代。

OobeStateService

职责: 管理首次运行状态

关键方法:

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

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

PluginInstallerService

职责: CLI 维护命令下的插件包安装(plugin install)。应用内插件市场安装由 Host 在启动时应用 pending 队列,不经过 Launcher 正常启动流程。

关键方法:

// 安装插件包CLI 维护)
LauncherResult InstallPackage(string sourcePath, string pluginsDirectory)

PluginUpgradeQueueService

职责: CLI 维护命令下的待处理插件升级(plugin update。Launcher 正常 GUI 启动流程不再应用 pending 队列Host 在 PluginRuntimeService.ApplyPendingPluginOperations() 中统一处理。

关键方法:

// 应用待处理的插件升级CLI 维护)
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 = await _updateEngine.ApplyPendingUpdateAsync();
        if (!updateResult.Success)
            Logger.Warn("Update apply failed, will try to launch existing version.");

        // 5. 启动主程序(插件 pending 由 Host 应用,不在 Launcher 启动步骤处理)
        var hostResult = await LaunchHostWithIpcAsync();
        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 做普通插件安装;市场安装会先把包下载到当前用户的 pending 队列,并在下一次 Host 启动、插件发现前应用。

开发指南

本地调试

直接运行 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()  // 添加新步骤
];

当前内置 OOBE 向导窗口(OobeWindow)内步骤顺序包含:开场 → 主题 → 数据保存位置启动与展示 → 隐私与遥测 → 完成。「启动与展示」写入 Host 的 settings.jsonPascalCase并在 Windows 下同步 Run 项,实现代码在 HostAppSettingsOobeMerger.csLauncherWindowsStartupService.cs,界面与逻辑挂在 Views/OobeWindow.axaml(.cs)

自定义更新源

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

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

相关文档

Current OOBE and Elevation Contract

  • OOBE state is a per-user truth source stored at %LOCALAPPDATA%\LanMountainDesktop\.launcher\state\oobe-state.json.
  • Same-user reinstall or upgrade must not re-enter OOBE.
  • first_run_completed is legacy compatibility data only and should not remain the long-term primary format.
  • Launch source values are normal, postinstall, apply-update, plugin-install, and debug-preview.
  • Auto-OOBE is allowed only for normal user-mode startup.
  • postinstall may open OOBE only when the launcher is not elevated and the user state path is available.
  • apply-update, plugin-install, and debug-preview must not auto-enter OOBE.
  • Allowed elevation paths are limited to the installer itself, full installer update application, and user-confirmed legacy uninstall.
  • Default plugin installation targets the current user's LocalAppData scope and must not request elevation by default.
  • In-app market installs are deferred Host-side operations: download and verify now, apply from the per-user pending queue on the next Host startup.

Public IPC Baseline

Launcher now consumes Host startup telemetry from the unified public IPC stack:

  • Host publishes StartupProgressMessage via lanmountain.launcher.startup-progress
  • Host publishes LoadingStateMessage via lanmountain.launcher.loading-state
  • Launcher connects through LanMountainDesktopIpcClient

The previous custom length-prefixed named-pipe transport is no longer the primary startup communication path.

Coordinator Guard

Launcher also owns a small per-user local coordinator used only between Launcher processes. It reserves startup-attempt.json before host launch, publishes a heartbeat, and exposes a local coordinator pipe for secondary Launchers. A secondary Launcher must attach to that coordinator or activate the existing Host through Public IPC instead of starting another Host process. See Launcher Coordinator.