16 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()
版本选择算法:
- 扫描所有
app-*目录 - 过滤掉带
.destroy或.partial标记的目录 - 优先选择带
.current标记的版本 - 如果没有
.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):
CleanupDeploymentsPhase— 清理旧部署ExistingHostProbePhase— 多实例 / 现有 Host 探测ApplyPendingUpdatePhase— 应用 pending 更新OobeGatePhase— OOBE 步骤LaunchHostPhase— 启动 HostMonitorStartupPhase— IPC 启动监控
GUI 入口: Shell/LauncherCompositionRoot + Shell/LauncherServiceRegistration(MS 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 步骤
- 实现
IOobeStep接口:
public class MyOobeStep : IOobeStep
{
public async Task RunAsync(CancellationToken cancellationToken)
{
// 显示 OOBE 窗口
// 等待用户完成
}
}
- 在
LauncherFlowCoordinator中注册:
_oobeSteps = [
new WelcomeOobeStep(_oobeStateService),
new MyOobeStep() // 添加新步骤
];
当前内置 OOBE 向导窗口(OobeWindow)内步骤顺序包含:开场 → 主题 → 数据保存位置 → 启动与展示 → 隐私与遥测 → 完成。「启动与展示」写入 Host 的 settings.json(PascalCase)并在 Windows 下同步 Run 项,实现代码在 HostAppSettingsOobeMerger.cs 与 LauncherWindowsStartupService.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_completedis legacy compatibility data only and should not remain the long-term primary format.- Launch source values are
normal,postinstall,apply-update,plugin-install, anddebug-preview. - Auto-OOBE is allowed only for normal user-mode startup.
postinstallmay open OOBE only when the launcher is not elevated and the user state path is available.apply-update,plugin-install, anddebug-previewmust 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
StartupProgressMessagevialanmountain.launcher.startup-progress - Host publishes
LoadingStateMessagevialanmountain.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.