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
This commit is contained in:
lincube
2026-04-21 20:59:52 +08:00
committed by GitHub
parent 03e32ee6cb
commit 4cb52e56c7
186 changed files with 44656 additions and 1248 deletions

View File

@@ -0,0 +1,9 @@
<Application xmlns="https://github.com/avaloniaui"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
xmlns:sty="using:FluentAvalonia.Styling"
x:Class="LanMountainDesktop.Launcher.App"
RequestedThemeVariant="Default">
<Application.Styles>
<sty:FluentAvaloniaTheme />
</Application.Styles>
</Application>

View File

@@ -0,0 +1,390 @@
using Avalonia;
using Avalonia.Controls;
using Avalonia.Controls.ApplicationLifetimes;
using Avalonia.Markup.Xaml;
using Avalonia.Threading;
using LanMountainDesktop.Launcher.Models;
using LanMountainDesktop.Launcher.Services;
using LanMountainDesktop.Launcher.Views;
namespace LanMountainDesktop.Launcher;
public partial class App : Application
{
public override void Initialize()
{
// 初始化日志记录器
Logger.Initialize();
Logger.Info("Launcher starting...");
AvaloniaXamlLoader.Load(this);
}
public override void OnFrameworkInitializationCompleted()
{
if (ApplicationLifetime is IClassicDesktopStyleApplicationLifetime desktop)
{
var context = LauncherRuntimeContext.Current;
// 调试模式:显示开发调试窗口
if (context.IsDebugMode)
{
var devDebugWindow = new DevDebugWindow();
devDebugWindow.Show();
// 调试模式下不自动启动正常流程,由开发者通过调试窗口控制
base.OnFrameworkInitializationCompleted();
return;
}
// 处理各界面的预览命令
if (HandlePreviewCommand(context, desktop))
{
base.OnFrameworkInitializationCompleted();
return;
}
// apply-update 模式:显示 UpdateWindow执行增量更新 + 插件升级
if (string.Equals(context.Command, "apply-update", StringComparison.OrdinalIgnoreCase))
{
// 先显示窗口,再启动后台任务
var updateWindow = new UpdateWindow();
updateWindow.Show();
_ = RunApplyUpdateWithWindowAsync(desktop, context, updateWindow);
}
else
{
// 先显示 Splash 窗口,确保应用程序不会立即退出
var splashWindow = new SplashWindow();
splashWindow.Show();
// 在 try-catch 块中实例化所有服务,确保任何异常都能被捕获
_ = RunCoordinatorWithSplashAsync(desktop, context, splashWindow);
}
}
base.OnFrameworkInitializationCompleted();
}
/// <summary>
/// 处理界面预览命令
/// </summary>
private bool HandlePreviewCommand(CommandContext context, IClassicDesktopStyleApplicationLifetime desktop)
{
var command = context.Command.ToLowerInvariant();
switch (command)
{
case "preview-splash":
Console.WriteLine("[Launcher] Preview mode: SplashWindow");
var splashWindow = new SplashWindow();
splashWindow.SetDebugMode(true);
splashWindow.Show();
_ = SimulateSplashPreviewAsync(desktop, splashWindow);
return true;
case "preview-error":
Console.WriteLine("[Launcher] Preview mode: ErrorWindow");
var errorWindow = new ErrorWindow();
errorWindow.SetErrorMessage("[预览模式] 这是一个错误页面预览。\n\n用于查看错误页面的样式和布局。");
errorWindow.Show();
_ = WaitForWindowCloseAsync(desktop, errorWindow);
return true;
case "preview-update":
Console.WriteLine("[Launcher] Preview mode: UpdateWindow");
var updateWindow = new UpdateWindow();
updateWindow.SetDebugMode(true);
updateWindow.Show();
_ = SimulateUpdatePreviewAsync(desktop, updateWindow);
return true;
case "preview-oobe":
Console.WriteLine("[Launcher] Preview mode: OobeWindow");
var oobeWindow = new OobeWindow();
oobeWindow.Show();
_ = SimulateOobePreviewAsync(desktop, oobeWindow);
return true;
case "preview-debug":
Console.WriteLine("[Launcher] Preview mode: DevDebugWindow");
var devDebugWindow = new DevDebugWindow();
devDebugWindow.Show();
return true;
default:
return false;
}
}
/// <summary>
/// 模拟 Splash 窗口预览
/// </summary>
private async Task SimulateSplashPreviewAsync(IClassicDesktopStyleApplicationLifetime desktop, SplashWindow window)
{
var stages = new[] { "initializing", "update", "plugins", "launch", "ready" };
var messages = new[] { "初始化...", "检查更新...", "检查插件...", "正在启动...", "就绪" };
var reporter = (ISplashStageReporter)window;
for (int i = 0; i < stages.Length; i++)
{
reporter.Report(stages[i], messages[i]);
await Task.Delay(800);
}
// 等待5秒后自动关闭
await Task.Delay(5000);
await Dispatcher.UIThread.InvokeAsync(() => desktop.Shutdown(0));
}
/// <summary>
/// 模拟 Update 窗口预览
/// </summary>
private async Task SimulateUpdatePreviewAsync(IClassicDesktopStyleApplicationLifetime desktop, UpdateWindow window)
{
var stages = new[] { "verify", "extract", "apply", "plugins", "cleanup" };
for (int i = 0; i < stages.Length; i++)
{
window.Report(stages[i], $"正在{GetStageName(stages[i])}...", (i + 1) * 20);
await Task.Delay(600);
}
window.ReportComplete(true, null);
// 等待3秒后自动关闭
await Task.Delay(3000);
await Dispatcher.UIThread.InvokeAsync(() => desktop.Shutdown(0));
string GetStageName(string stage) => stage switch
{
"verify" => "验证",
"extract" => "解压",
"apply" => "应用",
"plugins" => "升级插件",
"cleanup" => "清理",
_ => stage
};
}
/// <summary>
/// 模拟 OOBE 窗口预览
/// </summary>
private async Task SimulateOobePreviewAsync(IClassicDesktopStyleApplicationLifetime desktop, OobeWindow window)
{
try
{
// 等待用户点击开始按钮
await window.WaitForEnterAsync();
Console.WriteLine("[Launcher] OOBE preview completed by user");
}
catch (Exception ex)
{
Console.Error.WriteLine($"[Launcher] OOBE preview error: {ex.Message}");
}
// 用户点击后关闭应用程序
await Dispatcher.UIThread.InvokeAsync(() => desktop.Shutdown(0));
}
/// <summary>
/// 等待窗口关闭
/// </summary>
private async Task WaitForWindowCloseAsync(IClassicDesktopStyleApplicationLifetime desktop, Window window)
{
var tcs = new TaskCompletionSource();
window.Closed += (s, e) => tcs.TrySetResult();
await tcs.Task;
await Dispatcher.UIThread.InvokeAsync(() => desktop.Shutdown(0));
}
private static async Task RunCoordinatorWithSplashAsync(
IClassicDesktopStyleApplicationLifetime desktop,
CommandContext context,
SplashWindow splashWindow)
{
LauncherResult result;
ErrorWindow? errorWindow = null;
LauncherFlowCoordinator? coordinator = null;
try
{
// 在 try-catch 块中实例化所有服务,确保异常被捕获
var appRoot = Commands.ResolveAppRoot(context);
var deploymentLocator = new DeploymentLocator(appRoot);
// TODO: 从配置读取 GitHub 仓库信息
coordinator = new LauncherFlowCoordinator(
context,
deploymentLocator,
new OobeStateService(appRoot),
new UpdateEngineService(deploymentLocator),
new PluginInstallerService());
result = await coordinator.RunAsync(splashWindow).ConfigureAwait(false);
}
catch (Exception ex)
{
// 捕获异常并显示错误窗口
result = new LauncherResult
{
Success = false,
Stage = "launch",
Code = "exception",
Message = $"启动器发生错误: {ex.Message}",
ErrorMessage = ex.ToString()
};
Console.Error.WriteLine($"[Launcher] Exception caught: {ex}");
// 在 UI 线程显示错误窗口 - 使用更健壮的方式
try
{
await Dispatcher.UIThread.InvokeAsync(() =>
{
try
{
// 安全关闭 Splash 窗口
if (splashWindow.IsVisible && splashWindow.IsLoaded)
{
splashWindow.Close();
}
}
catch (Exception closeEx)
{
Console.Error.WriteLine($"[Launcher] Error closing splash window: {closeEx.Message}");
}
// 创建并显示错误窗口
try
{
errorWindow = new ErrorWindow();
errorWindow.SetErrorMessage($"启动器发生错误:\n{ex.Message}\n\n请检查应用安装是否完整或尝试重新安装。");
errorWindow.Show();
Console.WriteLine("[Launcher] ErrorWindow shown successfully");
}
catch (Exception windowEx)
{
Console.Error.WriteLine($"[Launcher] Failed to show ErrorWindow: {windowEx.Message}");
}
});
// 如果错误窗口成功显示,等待它关闭
if (errorWindow != null)
{
try
{
// 等待用户选择或窗口关闭
var errorResult = await errorWindow.WaitForChoiceAsync();
Console.WriteLine($"[Launcher] ErrorWindow result: {errorResult}");
}
catch (Exception waitEx)
{
Console.Error.WriteLine($"[Launcher] Error waiting for ErrorWindow: {waitEx.Message}");
// 如果等待失败至少给用户5秒时间看到错误信息
await Task.Delay(5000);
}
}
else
{
// 错误窗口未能显示等待5秒让用户看到控制台输出
await Task.Delay(5000);
}
}
catch (Exception uiEx)
{
// 最后的兜底:记录到控制台
Console.Error.WriteLine($"[Launcher] Critical error in UI thread: {uiEx.Message}");
await Task.Delay(3000);
}
}
await Commands.WriteResultIfNeededAsync(LauncherRuntimeContext.Current.GetOption("result"), result).ConfigureAwait(false);
Environment.ExitCode = result.Success ? 0 : 1;
await Dispatcher.UIThread.InvokeAsync(() => desktop.Shutdown(Environment.ExitCode), DispatcherPriority.Background);
}
/// <summary>
/// apply-update 模式:执行增量更新和插件升级,完成后自动退出
/// </summary>
private static async Task RunApplyUpdateWithWindowAsync(
IClassicDesktopStyleApplicationLifetime desktop,
CommandContext context,
UpdateWindow window)
{
var appRoot = Commands.ResolveAppRoot(context);
var deploymentLocator = new DeploymentLocator(appRoot);
var updateEngine = new UpdateEngineService(deploymentLocator);
var pluginInstaller = new PluginInstallerService();
var pluginUpgrades = new PluginUpgradeQueueService(pluginInstaller);
var success = true;
string? errorMessage = null;
try
{
// 1. 应用增量更新
await Dispatcher.UIThread.InvokeAsync(() => window.Report("verify", "正在验证更新...", 10));
var updateResult = await updateEngine.ApplyPendingUpdateAsync().ConfigureAwait(false);
if (!updateResult.Success)
{
success = false;
errorMessage = updateResult.Message;
}
// 2. 应用待处理的插件升级
if (success)
{
await Dispatcher.UIThread.InvokeAsync(() => window.Report("plugins", "正在升级插件...", 60));
var pluginsDir = context.GetOption("plugins-dir")
?? Path.Combine(appRoot, "plugins");
var queueResult = pluginUpgrades.ApplyPendingUpgrades(pluginsDir);
if (!queueResult.Success && queueResult.Code != "noop")
{
// 插件升级失败不阻断整体流程,仅记录到控制台
Console.Error.WriteLine($"Plugin upgrade had failures: {queueResult.Message}");
}
}
// 3. 清理旧版本保留至少3个版本以支持回滚
if (success)
{
await Dispatcher.UIThread.InvokeAsync(() => window.Report("cleanup", "正在清理...", 90));
deploymentLocator.CleanupOldDeployments(minVersionsToKeep: 3);
}
}
catch (Exception ex)
{
success = false;
errorMessage = ex.Message;
}
// 显示完成状态,短暂停留后关闭
await Dispatcher.UIThread.InvokeAsync(() => window.ReportComplete(success, errorMessage));
if (success)
{
// 成功:停留 1.5 秒让用户看到"更新完成"
await Task.Delay(1500);
}
else
{
// 失败:停留 5 秒让用户看到错误信息
await Task.Delay(5000);
}
await Commands.WriteResultIfNeededAsync(context.GetOption("result"), new LauncherResult
{
Success = success,
Stage = "apply-update",
Code = success ? "ok" : "failed",
Message = success ? "Update applied successfully." : (errorMessage ?? "Unknown error")
}).ConfigureAwait(false);
Environment.ExitCode = success ? 0 : 1;
await Dispatcher.UIThread.InvokeAsync(() => desktop.Shutdown(Environment.ExitCode), DispatcherPriority.Background);
}
}

View File

@@ -0,0 +1,28 @@
using System.Text.Json;
using System.Text.Json.Serialization;
using LanMountainDesktop.Launcher.Models;
using LanMountainDesktop.Launcher.Services;
using LanMountainDesktop.Shared.Contracts.Launcher;
namespace LanMountainDesktop.Launcher;
[JsonSourceGenerationOptions(WriteIndented = true, PropertyNamingPolicy = JsonKnownNamingPolicy.CamelCase)]
[JsonSerializable(typeof(SignedFileMap))]
[JsonSerializable(typeof(UpdateFileEntry))]
[JsonSerializable(typeof(PlondsUpdateMetadata))]
[JsonSerializable(typeof(PlondsFileMap))]
[JsonSerializable(typeof(PlondsComponentEntry))]
[JsonSerializable(typeof(PlondsFileEntry))]
[JsonSerializable(typeof(PlondsHashDescriptor))]
[JsonSerializable(typeof(SnapshotMetadata))]
[JsonSerializable(typeof(AppVersionInfo))]
[JsonSerializable(typeof(StartupProgressMessage))]
[JsonSerializable(typeof(LauncherResult))]
[JsonSerializable(typeof(HostDiscoveryConfig))]
[JsonSerializable(typeof(PluginManifest))]
[JsonSerializable(typeof(PendingUpgrade))]
[JsonSerializable(typeof(List<PendingUpgrade>))]
[JsonSerializable(typeof(GitHubRelease))]
[JsonSerializable(typeof(GitHubAsset))]
[JsonSerializable(typeof(List<GitHubRelease>))]
internal sealed partial class AppJsonContext : JsonSerializerContext;

Binary file not shown.

After

Width:  |  Height:  |  Size: 11 KiB

View File

@@ -0,0 +1,11 @@
-----BEGIN RSA PUBLIC KEY-----
MIIBigKCAYEAt3yev3f0D1AZthEmr7ZGeDTcjIOGwQgPGRK/qV1XMlYS96AYiqlQ
ToZyA+WrDAXOUHcpaIzei+GdieTs+IE0q64dvBY5+wJShKhGMdcJ+nibt6qfsgvX
M2jSuR5ubHP9HGqBQNgLYdGFyD/IA7cDG5AsrGTXtVIldbkSzHPJiAp69G3fu9Hi
J7o7jE3pzTTPoArpjcCheoK/+9vjZOmEmkw71uWvmtld8KgOYz5Wk+GbQ2mJk6NJ
5TNqvlnzbYl946f78XNvHnnguLEU7q4SK0vgE7F92G10xB1A6DCTZQINjz/RrO5s
M/r29/jRSZbdrqbDIufxzxSeU80ADd7THSAGTVltynO0prAKW4be7ZtKbZVXgMUO
NMyCZUPCvSZP21Z7FSVyzf3wWYbyn/iBYCogticl5GBlr6ChQ/kfOQCGysCuDRK0
/RJ+ukWQCpl41Sh33B3HltOoKNuVuOkhwiDvJ4ckDoupf+4hzTzqWCuZf3NLAsYf
FQiGowgqx0l5AgMBAAE=
-----END RSA PUBLIC KEY-----

View File

@@ -0,0 +1,93 @@
using System.Globalization;
namespace LanMountainDesktop.Launcher;
internal sealed class CommandContext
{
public string Command { get; }
public string SubCommand { get; }
public IReadOnlyDictionary<string, string> Options { get; }
/// <summary>
/// 原始命令行参数,用于转发给主程序
/// </summary>
public IReadOnlyList<string> RawArgs { get; }
public bool IsLegacyPluginInstall =>
Options.ContainsKey("source") &&
Options.ContainsKey("plugins-dir") &&
Options.ContainsKey("result");
/// <summary>
/// 是否处于调试模式(从 Rider/VS 等 IDE 启动)
/// 仅当明确指定 --debug 参数或调试器附加时才启用
/// </summary>
public bool IsDebugMode =>
Options.ContainsKey("debug") ||
System.Diagnostics.Debugger.IsAttached;
private CommandContext(string command, string subCommand, Dictionary<string, string> options, string[] rawArgs)
{
Command = command;
SubCommand = subCommand;
Options = options;
RawArgs = rawArgs;
}
public static CommandContext FromArgs(string[] args)
{
var options = ParseOptions(args);
var command = args.Length > 0 && !args[0].StartsWith("--", StringComparison.Ordinal)
? args[0]
: "launch";
var subCommand = args.Length > 1 && !args[1].StartsWith("--", StringComparison.Ordinal)
? args[1]
: string.Empty;
return new CommandContext(command, subCommand, options, args);
}
public string? GetOption(string key)
{
return Options.TryGetValue(key, out var value) ? value : null;
}
public int GetIntOption(string key, int fallback)
{
var raw = GetOption(key);
return int.TryParse(raw, NumberStyles.Integer, CultureInfo.InvariantCulture, out var value)
? value
: fallback;
}
private static Dictionary<string, string> ParseOptions(string[] args)
{
var values = new Dictionary<string, string>(StringComparer.OrdinalIgnoreCase);
for (var i = 0; i < args.Length; i++)
{
var current = args[i];
if (!current.StartsWith("--", StringComparison.Ordinal))
{
continue;
}
var key = current[2..];
if (string.IsNullOrWhiteSpace(key))
{
continue;
}
if (i + 1 < args.Length && !args[i + 1].StartsWith("--", StringComparison.Ordinal))
{
values[key] = args[++i];
continue;
}
values[key] = "true";
}
return values;
}
}

View File

@@ -0,0 +1,66 @@
<!-- AOT 发布配置文件 -->
<Project>
<PropertyGroup Condition="'$(PublishAot)' == 'true'">
<!-- 启用 Native AOT -->
<PublishAot>true</PublishAot>
<!-- 启用修剪以减小体积 -->
<PublishTrimmed>true</PublishTrimmed>
<TrimMode>partial</TrimMode>
<!-- 自包含(不依赖系统 .NET Runtime -->
<SelfContained>true</SelfContained>
<!-- 单文件发布 -->
<PublishSingleFile>true</PublishSingleFile>
<!-- 包含 native 库到单文件中 -->
<IncludeNativeLibrariesForSelfExtract>true</IncludeNativeLibrariesForSelfExtract>
<!-- 压缩单文件 -->
<EnableCompressionInSingleFile>true</EnableCompressionInSingleFile>
<!-- 优化大小 -->
<OptimizationPreference>Size</OptimizationPreference>
<!-- 禁用 ReadyToRunAOT 不需要) -->
<PublishReadyToRun>false</PublishReadyToRun>
<!-- 注意RuntimeIdentifier 由 CI/CD 工作流通过 -r 参数传入,不在此处硬编码 -->
<!-- 支持的平台win-x64, win-x86, linux-x64, osx-x64, osx-arm64 -->
</PropertyGroup>
<!-- AOT 兼容性设置 -->
<PropertyGroup>
<!-- 允许不安全代码(某些 AOT 场景需要) -->
<AllowUnsafeBlocks>true</AllowUnsafeBlocks>
<!-- 启用编译时绑定Avalonia 需要) -->
<AvaloniaUseCompiledBindingsByDefault>true</AvaloniaUseCompiledBindingsByDefault>
</PropertyGroup>
<!-- AOT 修剪配置 -->
<ItemGroup Condition="'$(PublishAot)' == 'true'">
<!-- 保留 Avalonia 必要的类型 -->
<TrimmerRootAssembly Include="Avalonia" />
<TrimmerRootAssembly Include="Avalonia.Desktop" />
<!-- 保留动态序列化类型 -->
<TrimmerRootAssembly Include="System.Text.Json" />
</ItemGroup>
<!-- AOT 兼容性:某些包可能需要特殊处理 -->
<PropertyGroup Condition="'$(PublishAot)' == 'true'">
<!-- 忽略某些警告 -->
<SuppressTrimAnalysisWarnings>false</SuppressTrimAnalysisWarnings>
<!-- 允许 IL 警告 -->
<TrimmerSingleWarn>false</TrimmerSingleWarn>
<!-- AOT 模式下禁用反射式 JSON 序列化,强制使用 Source Generator -->
<!-- 之前设置为 true 与 AOT 矛盾,导致 IL2026/IL3050 警告和运行时失败 -->
<JsonSerializerIsReflectionEnabledByDefault>false</JsonSerializerIsReflectionEnabledByDefault>
<!-- 启用 ISerializable 支持(部分库需要) -->
<IsAotCompatible>true</IsAotCompatible>
</PropertyGroup>
</Project>

View File

@@ -0,0 +1,29 @@
<!-- 单文件发布配置文件(非 AOT但接近单文件体验 -->
<Project>
<PropertyGroup Condition="'$(PublishSingleFileMode)' == 'true'">
<!-- 自包含 -->
<SelfContained>true</SelfContained>
<!-- 单文件发布 -->
<PublishSingleFile>true</PublishSingleFile>
<!-- 包含 native 库到单文件中 -->
<IncludeNativeLibrariesForSelfExtract>true</IncludeNativeLibrariesForSelfExtract>
<!-- 压缩单文件 -->
<EnableCompressionInSingleFile>true</EnableCompressionInSingleFile>
<!-- ReadyToRun 预编译(提升启动速度) -->
<PublishReadyToRun>true</PublishReadyToRun>
<!-- 修剪以减小体积 -->
<PublishTrimmed>true</PublishTrimmed>
<TrimMode>partial</TrimMode>
<!-- 优化大小 -->
<OptimizationPreference>Size</OptimizationPreference>
<!-- 目标运行时 -->
<RuntimeIdentifier>win-x64</RuntimeIdentifier>
</PropertyGroup>
</Project>

View File

@@ -0,0 +1,57 @@
<Project Sdk="Microsoft.NET.Sdk" TreatAsLocalProperty="Version;PackageVersion;InformationalVersion;AssemblyVersion;FileVersion">
<!-- 导入 AOT 配置 -->
<Import Project="LanMountainDesktop.Launcher.AOT.props" Condition="Exists('LanMountainDesktop.Launcher.AOT.props')" />
<PropertyGroup>
<OutputType>WinExe</OutputType>
<TargetFramework>net10.0</TargetFramework>
<Nullable>enable</Nullable>
<ImplicitUsings>enable</ImplicitUsings>
<Version>1.0.0</Version>
<PackageVersion>$(Version)</PackageVersion>
<AvaloniaUseCompiledBindingsByDefault>true</AvaloniaUseCompiledBindingsByDefault>
<!-- 应用程序图标 -->
<ApplicationIcon>Assets\logo.ico</ApplicationIcon>
</PropertyGroup>
<ItemGroup>
<!-- 只引用 Shared.ContractsIPC 协议) -->
<ProjectReference Include="..\LanMountainDesktop.Shared.Contracts\LanMountainDesktop.Shared.Contracts.csproj" />
</ItemGroup>
<ItemGroup>
<PackageReference Include="Avalonia" Version="11.3.12" />
<PackageReference Include="Avalonia.Desktop" Version="11.3.12" />
<PackageReference Include="FluentAvaloniaUI" Version="2.5.0" />
<PackageReference Include="Avalonia.Fonts.Inter" Version="11.3.12" />
<PackageReference Include="Tmds.DBus.Protocol" Version="0.92.0" />
</ItemGroup>
<!-- 资源文件 -->
<ItemGroup>
<!-- 公钥文件 -->
<None Include="Assets\public-key.pem" CopyToOutputDirectory="PreserveNewest" />
<!-- Avalonia 资源文件 -->
<AvaloniaResource Include="Assets\logo.ico" />
</ItemGroup>
<Target Name="CopyPublicKeyToLauncherDir" AfterTargets="Build">
<PropertyGroup>
<PublicKeySource>$(MSBuildProjectDirectory)\Assets\public-key.pem</PublicKeySource>
<PublicKeyDestDir>$(OutDir).launcher\update</PublicKeyDestDir>
</PropertyGroup>
<MakeDir Directories="$(PublicKeyDestDir)" />
<Copy SourceFiles="$(PublicKeySource)" DestinationFolder="$(PublicKeyDestDir)" SkipUnchangedFiles="true" />
</Target>
<Target Name="CopyPublicKeyToPublishDir" AfterTargets="Publish">
<PropertyGroup>
<PublishedKeySource>$(MSBuildProjectDirectory)\Assets\public-key.pem</PublishedKeySource>
<PublishedKeyDestDir>$(PublishDir).launcher\update</PublishedKeyDestDir>
</PropertyGroup>
<MakeDir Directories="$(PublishedKeyDestDir)" />
<Copy SourceFiles="$(PublishedKeySource)" DestinationFolder="$(PublishedKeyDestDir)" SkipUnchangedFiles="true" />
</Target>
</Project>

View File

@@ -0,0 +1,6 @@
namespace LanMountainDesktop.Launcher;
internal static class LauncherRuntimeContext
{
public static CommandContext Current { get; set; } = CommandContext.FromArgs([]);
}

View File

@@ -0,0 +1,42 @@
using System.Text.Json.Serialization;
namespace LanMountainDesktop.Launcher.Models;
internal sealed class LauncherResult
{
[JsonPropertyName("success")]
public bool Success { get; init; }
[JsonPropertyName("stage")]
public string Stage { get; init; } = string.Empty;
[JsonPropertyName("code")]
public string Code { get; init; } = "ok";
[JsonPropertyName("message")]
public string Message { get; init; } = string.Empty;
[JsonPropertyName("currentVersion")]
public string? CurrentVersion { get; init; }
[JsonPropertyName("targetVersion")]
public string? TargetVersion { get; init; }
[JsonPropertyName("rolledBackTo")]
public string? RolledBackTo { get; init; }
[JsonPropertyName("details")]
public Dictionary<string, string> Details { get; init; } = [];
[JsonPropertyName("installedPackagePath")]
public string? InstalledPackagePath { get; init; }
[JsonPropertyName("manifestId")]
public string? ManifestId { get; init; }
[JsonPropertyName("manifestName")]
public string? ManifestName { get; init; }
[JsonPropertyName("errorMessage")]
public string? ErrorMessage { get; init; }
}

View File

@@ -0,0 +1,24 @@
namespace LanMountainDesktop.Launcher.Models;
/// <summary>
/// GitHub Release 信息
/// </summary>
public sealed class ReleaseInfo
{
public required string TagName { get; init; }
public required string Name { get; init; }
public required bool Prerelease { get; init; }
public required DateTime PublishedAt { get; init; }
public required List<ReleaseAsset> Assets { get; init; }
public string? Body { get; init; }
}
/// <summary>
/// Release 资源文件
/// </summary>
public sealed class ReleaseAsset
{
public required string Name { get; init; }
public required string BrowserDownloadUrl { get; init; }
public required long Size { get; init; }
}

View File

@@ -0,0 +1,17 @@
namespace LanMountainDesktop.Launcher.Models;
/// <summary>
/// 更新频道
/// </summary>
public enum UpdateChannel
{
/// <summary>
/// 正式版 - 只检查 prerelease=false 的版本
/// </summary>
Stable,
/// <summary>
/// 预览版 - 检查所有版本(包括 prerelease=true)
/// </summary>
Preview
}

View File

@@ -0,0 +1,13 @@
namespace LanMountainDesktop.Launcher.Models;
/// <summary>
/// 更新检查结果
/// </summary>
public sealed class UpdateCheckResult
{
public bool HasUpdate { get; init; }
public string? LatestVersion { get; init; }
public string? CurrentVersion { get; init; }
public ReleaseInfo? Release { get; init; }
public string? ErrorMessage { get; init; }
}

View File

@@ -0,0 +1,144 @@
namespace LanMountainDesktop.Launcher.Models;
internal sealed class SignedFileMap
{
public string? FromVersion { get; set; }
public string? ToVersion { get; set; }
public string? Platform { get; set; }
public string? Arch { get; set; }
public List<UpdateFileEntry> Files { get; set; } = [];
}
internal sealed class UpdateFileEntry
{
public string Path { get; set; } = string.Empty;
public string? ArchivePath { get; set; }
public string Action { get; set; } = "replace";
public string? Sha256 { get; set; }
}
internal sealed class SnapshotMetadata
{
public string SnapshotId { get; set; } = string.Empty;
public string SourceVersion { get; set; } = string.Empty;
public string? TargetVersion { get; set; }
public DateTimeOffset CreatedAt { get; set; }
public string SourceDirectory { get; set; } = string.Empty;
public string? TargetDirectory { get; set; }
public string Status { get; set; } = "pending";
}
internal sealed class UpdateApplyResult
{
public bool Success { get; init; }
public string Message { get; init; } = string.Empty;
public string? FromVersion { get; init; }
public string? ToVersion { get; init; }
public string? RolledBackTo { get; init; }
}
internal sealed class PlondsUpdateMetadata
{
public string? DistributionId { get; set; }
public string? Channel { get; set; }
public string? SubChannel { get; set; }
public string? FromVersion { get; set; }
public string? ToVersion { get; set; }
public string? FileMapPath { get; set; }
public string? FileMapSignaturePath { get; set; }
public Dictionary<string, string> Metadata { get; set; } = [];
}
internal sealed class PlondsFileMap
{
public string? DistributionId { get; set; }
public string? FromVersion { get; set; }
public string? ToVersion { get; set; }
public string? Version { get; set; }
public string? Platform { get; set; }
public string? Arch { get; set; }
public Dictionary<string, string> Metadata { get; set; } = [];
public List<PlondsComponentEntry> Components { get; set; } = [];
public List<PlondsFileEntry> Files { get; set; } = [];
}
internal sealed class PlondsComponentEntry
{
public string Name { get; set; } = string.Empty;
public string? Version { get; set; }
public Dictionary<string, string> Metadata { get; set; } = [];
public List<PlondsFileEntry> Files { get; set; } = [];
}
internal sealed class PlondsFileEntry
{
public string Path { get; set; } = string.Empty;
public string? Action { get; set; } = "replace";
public string? Url { get; set; }
public string? ObjectUrl { get; set; }
public string? ObjectPath { get; set; }
public string? ObjectKey { get; set; }
public string? ArchivePath { get; set; }
public string? Sha256 { get; set; }
public string? Sha512 { get; set; }
public string? Sha512Base64 { get; set; }
public byte[]? Sha512Bytes { get; set; }
public PlondsHashDescriptor? Hash { get; set; }
public Dictionary<string, string> Metadata { get; set; } = [];
}
internal sealed class PlondsHashDescriptor
{
public string? Algorithm { get; set; }
public string? Value { get; set; }
public byte[]? Bytes { get; set; }
}

View File

@@ -0,0 +1,48 @@
using Avalonia;
using LanMountainDesktop.Launcher.Models;
using LanMountainDesktop.Launcher.Services;
namespace LanMountainDesktop.Launcher;
internal static class Program
{
[STAThread]
private static async Task<int> Main(string[] args)
{
var commandContext = CommandContext.FromArgs(args);
// 处理遗留插件安装命令
if (commandContext.IsLegacyPluginInstall)
{
var installer = new PluginInstallerService();
return await Commands.RunLegacyPluginInstallAsync(commandContext, installer).ConfigureAwait(false);
}
// apply-update 命令:启动 Avalonia GUI 显示更新进度窗口
if (string.Equals(commandContext.Command, "apply-update", StringComparison.OrdinalIgnoreCase))
{
LauncherRuntimeContext.Current = commandContext;
BuildAvaloniaApp().StartWithClassicDesktopLifetime(args);
return Environment.ExitCode;
}
// 处理其他 CLI 命令 (update, plugin, rollback 等)
if (!string.Equals(commandContext.Command, "launch", StringComparison.OrdinalIgnoreCase))
{
return await Commands.RunCliCommandAsync(commandContext).ConfigureAwait(false);
}
// 主启动流程: OOBE -> Splash -> 版本选择 -> 启动主程序
LauncherRuntimeContext.Current = commandContext;
BuildAvaloniaApp().StartWithClassicDesktopLifetime(args);
return Environment.ExitCode;
}
private static AppBuilder BuildAvaloniaApp()
{
return AppBuilder.Configure<App>()
.UsePlatformDetect()
.WithInterFont()
.LogToTrace();
}
}

View File

@@ -0,0 +1,29 @@
{
"$schema": "http://json.schemastore.org/launchsettings.json",
"profiles": {
"Launcher (Launch Mode)": {
"commandName": "Project",
"commandLineArgs": "launch",
"workingDirectory": "$(SolutionDir)",
"environmentVariables": {
"DOTNET_ENVIRONMENT": "Development"
}
},
"Launcher (Update Check)": {
"commandName": "Project",
"commandLineArgs": "update check",
"workingDirectory": "$(SolutionDir)",
"environmentVariables": {
"DOTNET_ENVIRONMENT": "Development"
}
},
"Launcher (Plugin Install)": {
"commandName": "Project",
"commandLineArgs": "plugin install <path-to-plugin.laapp>",
"workingDirectory": "$(SolutionDir)",
"environmentVariables": {
"DOTNET_ENVIRONMENT": "Development"
}
}
}
}

View File

@@ -0,0 +1,193 @@
using System.Text;
using System.Text.Json;
using LanMountainDesktop.Launcher.Models;
namespace LanMountainDesktop.Launcher.Services;
internal static class Commands
{
public static async Task<int> RunLegacyPluginInstallAsync(CommandContext context, PluginInstallerService installer)
{
var resultPath = context.GetOption("result");
LauncherResult result;
try
{
var source = context.GetOption("source") ?? string.Empty;
var pluginsDir = context.GetOption("plugins-dir") ?? string.Empty;
result = installer.InstallPackage(source, pluginsDir);
}
catch (Exception ex)
{
result = new LauncherResult
{
Success = false,
Stage = "plugin.install",
Code = "failed",
Message = ex.Message,
ErrorMessage = ex.Message
};
}
await WriteResultIfNeededAsync(resultPath, result).ConfigureAwait(false);
return result.Success ? 0 : 1;
}
public static async Task<int> RunCliCommandAsync(CommandContext context)
{
var appRoot = ResolveAppRoot(context);
var deploymentLocator = new DeploymentLocator(appRoot);
var updateEngine = new UpdateEngineService(deploymentLocator);
var pluginInstaller = new PluginInstallerService();
var pluginUpgrades = new PluginUpgradeQueueService(pluginInstaller);
LauncherResult result;
try
{
result = await ExecuteCoreAsync(context, updateEngine, pluginInstaller, pluginUpgrades).ConfigureAwait(false);
}
catch (Exception ex)
{
result = new LauncherResult
{
Success = false,
Stage = "command",
Code = "exception",
Message = ex.Message,
ErrorMessage = ex.Message
};
}
await WriteResultIfNeededAsync(context.GetOption("result"), result).ConfigureAwait(false);
return result.Success ? 0 : 1;
}
private static async Task<LauncherResult> ExecuteCoreAsync(
CommandContext context,
UpdateEngineService updateEngine,
PluginInstallerService pluginInstaller,
PluginUpgradeQueueService pluginUpgrades)
{
switch (context.Command.ToLowerInvariant())
{
case "update":
return await ExecuteUpdateAsync(context, updateEngine).ConfigureAwait(false);
case "plugin":
return ExecutePluginCommand(context, pluginInstaller, pluginUpgrades);
default:
return new LauncherResult
{
Success = false,
Stage = "command",
Code = "unsupported_command",
Message = $"Unsupported command '{context.Command}'."
};
}
}
private static async Task<LauncherResult> ExecuteUpdateAsync(CommandContext context, UpdateEngineService updateEngine)
{
return context.SubCommand.ToLowerInvariant() switch
{
"check" => updateEngine.CheckPendingUpdate(),
"apply" => await updateEngine.ApplyPendingUpdateAsync().ConfigureAwait(false),
"rollback" => updateEngine.RollbackLatest(),
"download" => await DownloadUpdatePayloadAsync(context, updateEngine).ConfigureAwait(false),
_ => new LauncherResult
{
Success = false,
Stage = "update",
Code = "unsupported_subcommand",
Message = $"Unsupported update sub-command '{context.SubCommand}'."
}
};
}
private static async Task<LauncherResult> DownloadUpdatePayloadAsync(CommandContext context, UpdateEngineService updateEngine)
{
return await updateEngine.DownloadAsync(
context.GetOption("manifest-url") ?? throw new InvalidOperationException("Missing --manifest-url."),
context.GetOption("signature-url") ?? throw new InvalidOperationException("Missing --signature-url."),
context.GetOption("archive-url") ?? throw new InvalidOperationException("Missing --archive-url."),
CancellationToken.None).ConfigureAwait(false);
}
private static LauncherResult ExecutePluginCommand(
CommandContext context,
PluginInstallerService pluginInstaller,
PluginUpgradeQueueService pluginUpgrades)
{
switch (context.SubCommand.ToLowerInvariant())
{
case "install":
{
var source = context.GetOption("source") ?? throw new InvalidOperationException("Missing --source.");
var pluginsDir = context.GetOption("plugins-dir") ?? throw new InvalidOperationException("Missing --plugins-dir.");
return pluginInstaller.InstallPackage(source, pluginsDir);
}
case "update":
{
var pluginsDir = context.GetOption("plugins-dir") ?? throw new InvalidOperationException("Missing --plugins-dir.");
return pluginUpgrades.ApplyPendingUpgrades(pluginsDir);
}
default:
return new LauncherResult
{
Success = false,
Stage = "plugin",
Code = "unsupported_subcommand",
Message = $"Unsupported plugin sub-command '{context.SubCommand}'."
};
}
}
public static async Task WriteResultIfNeededAsync(string? resultPath, LauncherResult result)
{
if (string.IsNullOrWhiteSpace(resultPath))
{
return;
}
var fullPath = Path.GetFullPath(resultPath);
var dir = Path.GetDirectoryName(fullPath);
if (!string.IsNullOrWhiteSpace(dir))
{
Directory.CreateDirectory(dir);
}
var json = JsonSerializer.Serialize(result, AppJsonContext.Default.LauncherResult);
await File.WriteAllTextAsync(fullPath, json, Encoding.UTF8).ConfigureAwait(false);
}
public static string ResolveAppRoot(CommandContext context)
{
var configured = context.GetOption("app-root");
if (!string.IsNullOrWhiteSpace(configured))
{
return Path.GetFullPath(configured);
}
var baseDir = AppContext.BaseDirectory;
// 发布版结构Launcher 和 app-* 目录在同一目录
// 检查当前目录是否有 app-* 子目录(发布版)
var appDirs = Directory.GetDirectories(baseDir, "app-*", SearchOption.TopDirectoryOnly);
if (appDirs.Length > 0)
{
// 找到 app-* 目录,说明是发布版结构
return baseDir;
}
// 开发环境:检查父目录是否有主程序
var parent = Path.GetFullPath(Path.Combine(baseDir, ".."));
var parentHost = OperatingSystem.IsWindows()
? Path.Combine(parent, "LanMountainDesktop.exe")
: Path.Combine(parent, "LanMountainDesktop");
if (File.Exists(parentHost))
{
return parent;
}
// 默认返回 baseDir
return baseDir;
}
}

View File

@@ -0,0 +1,40 @@
using Avalonia.Threading;
using LanMountainDesktop.Launcher.Views;
namespace LanMountainDesktop.Launcher.Services;
internal sealed class DeferredSplashStageReporter : ISplashStageReporter
{
private ISplashStageReporter? _inner;
private readonly List<(string Stage, string Message)> _pending = [];
public void SetInner(ISplashStageReporter inner)
{
_inner = inner;
foreach (var (stage, message) in _pending)
{
_inner.Report(stage, message);
}
_pending.Clear();
}
public void Report(string stage, string message)
{
if (_inner is not null)
{
_inner.Report(stage, message);
}
else
{
_pending.Add((stage, message));
}
}
public void ReportStage(string stage, int progress)
{
if (_inner is not null)
{
_inner.ReportStage(stage, progress);
}
}
}

View File

@@ -0,0 +1,468 @@
using System.Globalization;
using System.Text.Json;
using LanMountainDesktop.Launcher.Models;
using LanMountainDesktop.Shared.Contracts.Launcher;
namespace LanMountainDesktop.Launcher.Services;
internal sealed class DeploymentLocator
{
private readonly string _appRoot;
public DeploymentLocator(string appRoot)
{
_appRoot = appRoot;
}
public string GetAppRoot() => _appRoot;
public string? FindCurrentDeploymentDirectory()
{
Console.WriteLine("[DeploymentLocator] Searching for deployment directories (ClassIsland style)...");
if (!Directory.Exists(_appRoot))
{
Console.WriteLine("[DeploymentLocator] App root directory does not exist");
return null;
}
var executable = OperatingSystem.IsWindows() ? "LanMountainDesktop.exe" : "LanMountainDesktop";
try
{
var candidates = Directory.GetDirectories(_appRoot, "app-*", SearchOption.TopDirectoryOnly);
Console.WriteLine($"[DeploymentLocator] Found {candidates.Length} app-* directories");
// ClassIsland 风格的查询:先筛选,后排序
var validInstallations = candidates
.Where(path =>
{
var hasDestroy = File.Exists(Path.Combine(path, ".destroy"));
var hasPartial = File.Exists(Path.Combine(path, ".partial"));
var hasExe = File.Exists(Path.Combine(path, executable));
var hasCurrent = File.Exists(Path.Combine(path, ".current"));
var version = ParseVersionFromDirectory(path);
Console.WriteLine($"[DeploymentLocator] Candidate: {Path.GetFileName(path)} | " +
$"Version={version} | " +
$"Current={hasCurrent} | " +
$"Destroy={hasDestroy} | " +
$"Partial={hasPartial} | " +
$"HasExe={hasExe}");
return !hasDestroy && !hasPartial && hasExe;
})
.Select(path => new
{
Path = path,
Version = ParseVersionFromDirectory(path),
HasCurrentMarker = File.Exists(Path.Combine(path, ".current"))
})
.OrderBy(x => x.HasCurrentMarker ? 0 : 1) // .current 标记的排前面
.ThenByDescending(x => x.Version) // 然后按版本号降序
.ToList();
if (validInstallations.Count == 0)
{
Console.WriteLine("[DeploymentLocator] No valid deployment directories found");
return null;
}
var best = validInstallations[0];
Console.WriteLine($"[DeploymentLocator] Selected: {Path.GetFileName(best.Path)} (current={best.HasCurrentMarker}, version={best.Version})");
return best.Path;
}
catch (Exception ex)
{
Console.Error.WriteLine($"[DeploymentLocator] Error searching for deployments: {ex}");
return null;
}
}
public string? ResolveHostExecutablePath()
{
// 使用新的灵活定位器
var options = new HostDiscoveryOptions
{
ExecutableName = "LanMountainDesktop",
PreferDevModeConfig = true,
RecursiveSearch = false, // 默认不启用递归搜索以提高性能
AdditionalSearchPaths = new List<string>
{
// 可以通过配置文件或环境变量添加更多路径
"${AppRoot}",
"${AppRoot}/..",
"${BaseDirectory}/../..",
}
};
var locator = new FlexibleHostLocator(_appRoot, options);
var result = locator.ResolveHostExecutablePath();
if (result != null)
{
return result;
}
// 回退到旧逻辑(作为备选)
return ResolveHostExecutablePathLegacy();
}
/// <summary>
/// 传统的主程序路径解析(作为备选)
/// </summary>
private string? ResolveHostExecutablePathLegacy()
{
var executable = OperatingSystem.IsWindows() ? "LanMountainDesktop.exe" : "LanMountainDesktop";
// 1. 首先查找 app-{version} 目录(生产环境)
var currentDeployment = FindCurrentDeploymentDirectory();
if (!string.IsNullOrWhiteSpace(currentDeployment))
{
var inDeployment = Path.Combine(currentDeployment, executable);
if (File.Exists(inDeployment))
{
return inDeployment;
}
}
// 2. 查找 Launcher 所在目录(开发环境 - 直接运行)
var inRoot = Path.Combine(_appRoot, executable);
if (File.Exists(inRoot))
{
return inRoot;
}
// 3. 查找父目录(开发环境 - 从 Launcher 项目运行)
var parent = Path.GetFullPath(Path.Combine(_appRoot, ".."));
var inParent = Path.Combine(parent, executable);
if (File.Exists(inParent))
{
return inParent;
}
// 4. 开发模式:如果启用了开发模式,优先使用保存的自定义路径
if (Views.ErrorWindow.CheckDevModeEnabled())
{
// 4.1 首先检查保存的自定义路径
var savedCustomPath = Views.ErrorWindow.GetSavedCustomHostPath();
if (!string.IsNullOrWhiteSpace(savedCustomPath) && File.Exists(savedCustomPath))
{
return savedCustomPath;
}
// 4.2 扫描开发路径
var devPath = ScanDevelopmentPaths(executable);
if (!string.IsNullOrWhiteSpace(devPath))
{
return devPath;
}
}
// 5. 开发模式:查找主程序项目的输出目录
var devPaths = GetDevelopmentPaths(executable);
foreach (var devPath in devPaths)
{
if (File.Exists(devPath))
{
return devPath;
}
}
return null;
}
/// <summary>
/// 扫描开发路径(开发模式)
/// </summary>
private static string? ScanDevelopmentPaths(string executable)
{
var possiblePaths = new[]
{
// 从 Launcher 项目运行
Path.Combine(AppContext.BaseDirectory, "..", "..", "LanMountainDesktop", "bin", "Debug", "net10.0", executable),
Path.Combine(AppContext.BaseDirectory, "..", "..", "LanMountainDesktop", "bin", "Release", "net10.0", executable),
// 从解决方案根目录运行
Path.Combine(AppContext.BaseDirectory, "..", "LanMountainDesktop", "bin", "Debug", "net10.0", executable),
Path.Combine(AppContext.BaseDirectory, "..", "LanMountainDesktop", "bin", "Release", "net10.0", executable),
// dev-test 目录
Path.Combine(AppContext.BaseDirectory, "..", "dev-test", "app-1.0.0-dev", executable),
};
foreach (var path in possiblePaths.Select(Path.GetFullPath).Distinct())
{
if (File.Exists(path))
{
return path;
}
}
return null;
}
/// <summary>
/// 获取开发环境可能的主程序路径
/// </summary>
private static IEnumerable<string> GetDevelopmentPaths(string executable)
{
// 获取 Launcher 所在目录
var launcherDir = AppContext.BaseDirectory;
// 可能的开发目录结构
var possiblePaths = new[]
{
// 从 Launcher 项目运行:..\LanMountainDesktop\bin\Debug\net10.0\LanMountainDesktop.exe
Path.Combine(launcherDir, "..", "..", "LanMountainDesktop", "bin", "Debug", "net10.0", executable),
Path.Combine(launcherDir, "..", "..", "LanMountainDesktop", "bin", "Release", "net10.0", executable),
// 从解决方案根目录运行LanMountainDesktop\bin\Debug\net10.0\LanMountainDesktop.exe
Path.Combine(launcherDir, "..", "LanMountainDesktop", "bin", "Debug", "net10.0", executable),
Path.Combine(launcherDir, "..", "LanMountainDesktop", "bin", "Release", "net10.0", executable),
// 从 dev-test 目录运行
Path.Combine(launcherDir, "..", "dev-test", "app-1.0.0-dev", executable),
};
return possiblePaths.Select(Path.GetFullPath).Distinct();
}
public string GetCurrentVersion()
{
var deployment = FindCurrentDeploymentDirectory();
if (string.IsNullOrWhiteSpace(deployment))
{
return "0.0.0";
}
return ParseVersionTextFromDirectory(deployment) ?? "0.0.0";
}
public string BuildNextDeploymentDirectory(string targetVersion)
{
var sanitized = string.IsNullOrWhiteSpace(targetVersion) ? "0.0.0" : targetVersion.Trim();
var index = 0;
while (true)
{
var candidate = Path.Combine(_appRoot, $"app-{sanitized}-{index.ToString(CultureInfo.InvariantCulture)}");
if (!Directory.Exists(candidate))
{
return candidate;
}
index++;
}
}
/// <summary>
/// 清理旧版本部署保留最近的N个版本
/// </summary>
/// <param name="minVersionsToKeep">最少保留版本数默认3个</param>
public void CleanupOldDeployments(int minVersionsToKeep = 3)
{
try
{
Console.WriteLine($"[DeploymentLocator] Starting cleanup with retention policy: keep at least {minVersionsToKeep} versions");
if (!Directory.Exists(_appRoot))
{
return;
}
var candidates = Directory.GetDirectories(_appRoot, "app-*", SearchOption.TopDirectoryOnly);
// 过滤掉无效部署目录排除partial按版本排序
var validDeployments = candidates
.Where(path => !File.Exists(Path.Combine(path, ".partial")))
.Select(path => new
{
Path = path,
Version = ParseVersionFromDirectory(path),
IsDestroyed = File.Exists(Path.Combine(path, ".destroy")),
IsCurrent = File.Exists(Path.Combine(path, ".current"))
})
.OrderByDescending(item => item.Version)
.ToList();
Console.WriteLine($"[DeploymentLocator] Found {validDeployments.Count} valid deployments");
// 确定要保留的版本
var versionsToKeep = new HashSet<string>();
// 1. 总是保留当前版本
var currentVersion = validDeployments.FirstOrDefault(d => d.IsCurrent);
if (currentVersion != null)
{
versionsToKeep.Add(currentVersion.Path);
Console.WriteLine($"[DeploymentLocator] Keep current version: {currentVersion.Path}");
}
// 2. 保留最近的N个有效版本不包括已标记destroy的
var activeVersions = validDeployments
.Where(d => !d.IsDestroyed)
.Take(minVersionsToKeep)
.ToList();
foreach (var ver in activeVersions)
{
versionsToKeep.Add(ver.Path);
Console.WriteLine($"[DeploymentLocator] Keep recent version: {ver.Path}");
}
// 3. 保留有快照的版本(用于回滚)
var snapshotDir = Path.Combine(_appRoot, ".launcher", "snapshots");
if (Directory.Exists(snapshotDir))
{
try
{
var snapshotFiles = Directory.GetFiles(snapshotDir, "*.json", SearchOption.TopDirectoryOnly);
foreach (var snapshotFile in snapshotFiles)
{
try
{
var json = File.ReadAllText(snapshotFile);
var snapshot = System.Text.Json.JsonSerializer.Deserialize(json, AppJsonContext.Default.SnapshotMetadata);
if (snapshot != null && !string.IsNullOrEmpty(snapshot.SourceDirectory))
{
if (Directory.Exists(snapshot.SourceDirectory))
{
versionsToKeep.Add(snapshot.SourceDirectory);
Console.WriteLine($"[DeploymentLocator] Keep version for rollback: {snapshot.SourceDirectory}");
}
}
}
catch
{
// 忽略快照解析错误
}
}
}
catch
{
// 忽略快照目录访问错误
}
}
// 清理不需要的版本
foreach (var deployment in validDeployments)
{
if (versionsToKeep.Contains(deployment.Path))
{
// 保留此版本如果之前标记了destroy则取消标记
if (deployment.IsDestroyed)
{
try
{
File.Delete(Path.Combine(deployment.Path, ".destroy"));
Console.WriteLine($"[DeploymentLocator] Unmarked for deletion (kept): {deployment.Path}");
}
catch
{
// 忽略取消标记失败
}
}
continue;
}
// 如果还没标记destroy的先标记
if (!deployment.IsDestroyed)
{
try
{
File.WriteAllText(Path.Combine(deployment.Path, ".destroy"), string.Empty);
Console.WriteLine($"[DeploymentLocator] Marked for deletion: {deployment.Path}");
}
catch
{
// 忽略标记失败
}
}
// 尝试删除
try
{
Directory.Delete(deployment.Path, recursive: true);
Console.WriteLine($"[DeploymentLocator] Deleted: {deployment.Path}");
}
catch
{
// 忽略删除失败(可能文件被占用),下次启动再试
Console.WriteLine($"[DeploymentLocator] Failed to delete (will retry later): {deployment.Path}");
}
}
}
catch (Exception ex)
{
Console.Error.WriteLine($"[DeploymentLocator] Cleanup failed: {ex.Message}");
// 忽略清理失败
}
}
/// <summary>
/// 仅清理已标记为.destroy的部署兼容旧方法
/// </summary>
[Obsolete("Use CleanupOldDeployments instead")]
public void CleanupDestroyedDeployments()
{
CleanupOldDeployments(3);
}
public static Version ParseVersionFromDirectory(string path)
{
var text = ParseVersionTextFromDirectory(path);
return Version.TryParse(text, out var version) ? version : new Version(0, 0, 0);
}
private static string? ParseVersionTextFromDirectory(string path)
{
var fileName = Path.GetFileName(path);
if (string.IsNullOrWhiteSpace(fileName))
{
return null;
}
var segments = fileName.Split('-');
if (segments.Length < 2)
{
return null;
}
return segments[1];
}
/// <summary>
/// 从部署目录读取版本信息
/// </summary>
public AppVersionInfo GetVersionInfo()
{
var deploymentDir = FindCurrentDeploymentDirectory();
if (!string.IsNullOrWhiteSpace(deploymentDir))
{
var versionFile = Path.Combine(deploymentDir, "version.json");
if (File.Exists(versionFile))
{
try
{
var json = File.ReadAllText(versionFile);
var info = JsonSerializer.Deserialize(json, AppJsonContext.Default.AppVersionInfo);
if (info is not null)
{
return info;
}
}
catch
{
// 忽略读取失败,回退到默认值
}
}
}
// 回退:从目录名解析版本,使用默认开发代号
return new AppVersionInfo
{
Version = GetCurrentVersion(),
Codename = "Administrate" // 默认开发代号
};
}
}

View File

@@ -0,0 +1,629 @@
using System.Diagnostics;
using System.Text.Json;
namespace LanMountainDesktop.Launcher.Services;
/// <summary>
/// 灵活的主程序定位器
/// </summary>
internal sealed class FlexibleHostLocator
{
private readonly HostDiscoveryOptions _options;
private readonly string _appRoot;
private readonly DeploymentLocator _deploymentLocator;
public FlexibleHostLocator(string appRoot, HostDiscoveryOptions? options = null)
{
_appRoot = appRoot;
_options = options ?? new HostDiscoveryOptions();
_deploymentLocator = new DeploymentLocator(appRoot);
}
/// <summary>
/// 解析主程序可执行文件路径
/// </summary>
public string? ResolveHostExecutablePath()
{
var executable = GetExecutableName();
var searchContext = new SearchContext
{
ExecutableName = executable,
AppRoot = _appRoot,
Options = _options
};
// ========== 第一阶段:标准路径查找(快速路径)==========
// 1. 检查环境变量指定的路径(最高优先级 - 用于调试和特殊场景)
var envPath = GetPathFromEnvironment();
if (!string.IsNullOrWhiteSpace(envPath))
{
var validated = ValidateAndReturn(envPath, "environment variable");
if (validated != null) return validated;
}
// 2. 使用 DeploymentLocatorClassIsland 风格的简洁查询 - 优先)
Console.WriteLine("[FlexibleHostLocator] Trying quick path: DeploymentLocator.FindCurrentDeploymentDirectory()");
var deploymentDir = _deploymentLocator.FindCurrentDeploymentDirectory();
if (!string.IsNullOrWhiteSpace(deploymentDir))
{
var deploymentExePath = Path.Combine(deploymentDir, executable);
if (File.Exists(deploymentExePath))
{
Console.WriteLine($"[FlexibleHostLocator] Quick path found: {deploymentExePath}");
return deploymentExePath;
}
Console.WriteLine($"[FlexibleHostLocator] Quick path found dir but no exe: {deploymentExePath}");
}
// 3. 快速路径失败,尝试旧的 SearchDeploymentDirectories 作为 fallback
Console.WriteLine("[FlexibleHostLocator] Quick path failed, falling back to SearchDeploymentDirectories");
var deploymentPath = SearchDeploymentDirectories(searchContext);
if (!string.IsNullOrWhiteSpace(deploymentPath))
{
return deploymentPath;
}
// 4. 检查 Launcher 同级目录(便携模式)
var portablePath = SearchPortableLocation(searchContext);
if (!string.IsNullOrWhiteSpace(portablePath))
{
return portablePath;
}
// ========== 第二阶段:灵活查找(标准路径找不到时)==========
// 5. 检查配置文件中的路径 - 用户自定义配置
var configPath = GetPathFromConfigFile();
if (!string.IsNullOrWhiteSpace(configPath))
{
var validated = ValidateAndReturn(configPath, "config file");
if (validated != null) return validated;
}
// 5. 搜索附近目录(向上、向下各一层)
var nearbyPath = SearchNearbyDirectories(searchContext);
if (!string.IsNullOrWhiteSpace(nearbyPath))
{
return nearbyPath;
}
// 7. 开发模式:检查保存的自定义路径
if (_options.PreferDevModeConfig && Views.ErrorWindow.CheckDevModeEnabled())
{
var savedPath = Views.ErrorWindow.GetSavedCustomHostPath();
if (!string.IsNullOrWhiteSpace(savedPath))
{
var validated = ValidateAndReturn(savedPath, "saved dev mode path");
if (validated != null) return validated;
}
}
// 8. 搜索标准开发路径
var devPath = SearchDevelopmentPaths(searchContext);
if (!string.IsNullOrWhiteSpace(devPath))
{
return devPath;
}
// 9. 搜索额外的配置路径
var additionalPath = SearchAdditionalPaths(searchContext);
if (!string.IsNullOrWhiteSpace(additionalPath))
{
return additionalPath;
}
// 10. 递归搜索(如果启用)
if (_options.RecursiveSearch)
{
var recursivePath = SearchRecursively(searchContext);
if (!string.IsNullOrWhiteSpace(recursivePath))
{
return recursivePath;
}
}
return null;
}
/// <summary>
/// 从环境变量获取路径
/// </summary>
private string? GetPathFromEnvironment()
{
if (string.IsNullOrWhiteSpace(_options.CustomPathEnvVar))
{
return null;
}
var path = Environment.GetEnvironmentVariable(_options.CustomPathEnvVar);
return path;
}
/// <summary>
/// 从配置文件获取路径
/// </summary>
private string? GetPathFromConfigFile()
{
if (string.IsNullOrWhiteSpace(_options.ConfigFileName))
{
return null;
}
var configPath = Path.Combine(_appRoot, _options.ConfigFileName);
if (!File.Exists(configPath))
{
return null;
}
try
{
var json = File.ReadAllText(configPath);
var config = JsonSerializer.Deserialize(json, AppJsonContext.Default.HostDiscoveryConfig);
if (config?.HostPath != null && File.Exists(config.HostPath))
{
return config.HostPath;
}
}
catch
{
// 忽略配置文件读取错误
}
return null;
}
/// <summary>
/// 搜索部署目录
/// </summary>
private string? SearchDeploymentDirectories(SearchContext context)
{
if (!Directory.Exists(_appRoot))
{
return null;
}
try
{
// 查找 app-* 目录
var appDirs = Directory.GetDirectories(_appRoot, "app-*", SearchOption.TopDirectoryOnly)
.Where(dir => !File.Exists(Path.Combine(dir, ".destroy")))
.Where(dir => !File.Exists(Path.Combine(dir, ".partial")))
.ToList();
// 优先选择带 .current 标记的
var currentMarked = appDirs
.Where(dir => File.Exists(Path.Combine(dir, ".current")))
.Select(dir => Path.Combine(dir, context.ExecutableName))
.FirstOrDefault(File.Exists);
if (currentMarked != null)
{
return currentMarked;
}
// 选择版本号最高的
var latest = appDirs
.Select(dir => new
{
Dir = dir,
Version = ParseVersionFromDirectoryName(dir)
})
.OrderByDescending(x => x.Version)
.Select(x => Path.Combine(x.Dir, context.ExecutableName))
.FirstOrDefault(File.Exists);
return latest;
}
catch
{
return null;
}
}
/// <summary>
/// 搜索便携模式位置Launcher 同级目录)
/// </summary>
private string? SearchPortableLocation(SearchContext context)
{
try
{
var launcherDir = AppContext.BaseDirectory;
var portablePath = Path.Combine(launcherDir, context.ExecutableName);
if (File.Exists(portablePath))
{
return portablePath;
}
}
catch
{
// 忽略错误
}
return null;
}
/// <summary>
/// 搜索附近目录(灵活查找,适用于各种部署场景)
/// </summary>
private string? SearchNearbyDirectories(SearchContext context)
{
try
{
var searchDirs = new List<string>();
// Launcher 所在目录
var launcherDir = AppContext.BaseDirectory;
searchDirs.Add(launcherDir);
// 上级目录
var parentDir = Path.GetFullPath(Path.Combine(launcherDir, ".."));
if (Directory.Exists(parentDir))
{
searchDirs.Add(parentDir);
}
// 上上级目录
var grandparentDir = Path.GetFullPath(Path.Combine(launcherDir, "..", ".."));
if (Directory.Exists(grandparentDir))
{
searchDirs.Add(grandparentDir);
}
// AppRoot 及其上级
if (!string.IsNullOrWhiteSpace(_appRoot) && Directory.Exists(_appRoot))
{
searchDirs.Add(_appRoot);
var appParent = Path.GetFullPath(Path.Combine(_appRoot, ".."));
if (Directory.Exists(appParent))
{
searchDirs.Add(appParent);
}
}
// 去重后搜索
foreach (var dir in searchDirs.Distinct(StringComparer.OrdinalIgnoreCase))
{
// 直接搜索
var directPath = Path.Combine(dir, context.ExecutableName);
if (File.Exists(directPath))
{
return directPath;
}
// 搜索子目录(一层)
if (Directory.Exists(dir))
{
foreach (var subDir in Directory.GetDirectories(dir))
{
var subPath = Path.Combine(subDir, context.ExecutableName);
if (File.Exists(subPath))
{
return subPath;
}
}
}
}
}
catch
{
// 忽略搜索错误
}
return null;
}
/// <summary>
/// 搜索开发路径
/// </summary>
private string? SearchDevelopmentPaths(SearchContext context)
{
// 获取 Launcher 所在目录
var launcherDir = AppContext.BaseDirectory;
// 动态构建可能的开发路径(支持不同的项目结构)
var possiblePaths = new List<string>();
// 从解决方案根目录搜索(支持不同的解决方案结构)
var solutionRoot = FindSolutionRoot(launcherDir);
if (!string.IsNullOrWhiteSpace(solutionRoot))
{
// 搜索所有可能的 bin 目录
possiblePaths.AddRange(SearchBinDirectories(solutionRoot, context.ExecutableName));
}
// 添加硬编码的备用路径
possiblePaths.AddRange(new[]
{
Path.Combine(launcherDir, "..", "..", "LanMountainDesktop", "bin", "Debug", "net10.0", context.ExecutableName),
Path.Combine(launcherDir, "..", "..", "LanMountainDesktop", "bin", "Release", "net10.0", context.ExecutableName),
Path.Combine(launcherDir, "..", "LanMountainDesktop", "bin", "Debug", "net10.0", context.ExecutableName),
Path.Combine(launcherDir, "..", "LanMountainDesktop", "bin", "Release", "net10.0", context.ExecutableName),
});
foreach (var path in possiblePaths.Select(Path.GetFullPath).Distinct())
{
if (File.Exists(path))
{
return path;
}
}
return null;
}
/// <summary>
/// 搜索额外的配置路径
/// </summary>
private string? SearchAdditionalPaths(SearchContext context)
{
if (_options.AdditionalSearchPaths == null || !_options.AdditionalSearchPaths.Any())
{
return null;
}
foreach (var pattern in _options.AdditionalSearchPaths)
{
try
{
// 替换变量
var expandedPattern = ExpandVariables(pattern);
// 支持通配符
if (expandedPattern.Contains('*') || expandedPattern.Contains('?'))
{
var dir = Path.GetDirectoryName(expandedPattern) ?? _appRoot;
var filePattern = Path.GetFileName(expandedPattern);
if (Directory.Exists(dir))
{
var matches = Directory.GetFiles(dir, filePattern, SearchOption.TopDirectoryOnly);
var validMatch = matches.FirstOrDefault(File.Exists);
if (validMatch != null)
{
return validMatch;
}
}
}
else if (File.Exists(expandedPattern))
{
return expandedPattern;
}
}
catch
{
// 忽略搜索错误
}
}
return null;
}
/// <summary>
/// 递归搜索
/// </summary>
private string? SearchRecursively(SearchContext context)
{
try
{
var searchDirs = new[] { _appRoot, Path.GetFullPath(Path.Combine(_appRoot, "..")) };
foreach (var searchDir in searchDirs.Where(Directory.Exists))
{
var result = SearchDirectoryRecursively(searchDir, context.ExecutableName, 0);
if (result != null)
{
return result;
}
}
}
catch
{
// 忽略递归搜索错误
}
return null;
}
/// <summary>
/// 递归搜索目录
/// </summary>
private string? SearchDirectoryRecursively(string dir, string executableName, int depth)
{
if (depth > _options.MaxRecursionDepth)
{
return null;
}
try
{
// 检查当前目录
var directPath = Path.Combine(dir, executableName);
if (File.Exists(directPath))
{
return directPath;
}
// 检查子目录
foreach (var subDir in Directory.GetDirectories(dir))
{
// 跳过某些目录
var dirName = Path.GetFileName(subDir).ToLowerInvariant();
if (dirName is ".git" or "node_modules" or ".vs" or "obj" or ".launcher")
{
continue;
}
var result = SearchDirectoryRecursively(subDir, executableName, depth + 1);
if (result != null)
{
return result;
}
}
}
catch
{
// 忽略访问错误
}
return null;
}
/// <summary>
/// 查找解决方案根目录
/// </summary>
private string? FindSolutionRoot(string startDir)
{
var current = new DirectoryInfo(startDir);
while (current != null)
{
// 查找 .sln 文件
if (current.GetFiles("*.sln").Any())
{
return current.FullName;
}
// 查找 .git 目录作为备选
if (current.GetDirectories(".git").Any())
{
return current.FullName;
}
current = current.Parent;
}
return null;
}
/// <summary>
/// 搜索 bin 目录
/// </summary>
private IEnumerable<string> SearchBinDirectories(string root, string executableName)
{
var results = new List<string>();
try
{
// 查找所有 bin 目录
var binDirs = Directory.GetDirectories(root, "bin", SearchOption.AllDirectories);
foreach (var binDir in binDirs)
{
// 检查 Debug 和 Release 子目录
var configDirs = new[] { "Debug", "Release" };
foreach (var config in configDirs)
{
var configPath = Path.Combine(binDir, config);
if (Directory.Exists(configPath))
{
// 检查所有 net* 子目录
var frameworkDirs = Directory.GetDirectories(configPath, "net*");
foreach (var fwDir in frameworkDirs)
{
var exePath = Path.Combine(fwDir, executableName);
if (File.Exists(exePath))
{
results.Add(exePath);
}
}
}
}
}
}
catch
{
// 忽略搜索错误
}
return results;
}
/// <summary>
/// 验证路径并返回
/// </summary>
private string? ValidateAndReturn(string path, string source)
{
if (File.Exists(path))
{
Debug.WriteLine($"Found host executable from {source}: {path}");
return path;
}
// 尝试添加 .exeWindows
if (OperatingSystem.IsWindows() && !path.EndsWith(".exe", StringComparison.OrdinalIgnoreCase))
{
var withExe = path + ".exe";
if (File.Exists(withExe))
{
Debug.WriteLine($"Found host executable from {source}: {withExe}");
return withExe;
}
}
return null;
}
/// <summary>
/// 获取可执行文件名
/// </summary>
private string GetExecutableName()
{
var name = _options.ExecutableName;
if (OperatingSystem.IsWindows() && !name.EndsWith(".exe", StringComparison.OrdinalIgnoreCase))
{
name += ".exe";
}
return name;
}
/// <summary>
/// 展开路径变量
/// </summary>
private string ExpandVariables(string path)
{
return path
.Replace("${AppRoot}", _appRoot)
.Replace("${BaseDirectory}", AppContext.BaseDirectory)
.Replace("${UserProfile}", Environment.GetFolderPath(Environment.SpecialFolder.UserProfile))
.Replace("${LocalAppData}", Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData));
}
/// <summary>
/// 从目录名解析版本
/// </summary>
private static Version ParseVersionFromDirectoryName(string path)
{
var fileName = Path.GetFileName(path);
if (string.IsNullOrWhiteSpace(fileName))
{
return new Version(0, 0, 0);
}
var segments = fileName.Split('-');
if (segments.Length < 2)
{
return new Version(0, 0, 0);
}
return Version.TryParse(segments[1], out var version) ? version : new Version(0, 0, 0);
}
/// <summary>
/// 搜索上下文
/// </summary>
private class SearchContext
{
public required string ExecutableName { get; set; }
public required string AppRoot { get; set; }
public required HostDiscoveryOptions Options { get; set; }
}
}
/// <summary>
/// 发现配置文件
/// </summary>
internal class HostDiscoveryConfig
{
public string? HostPath { get; set; }
public List<string>? AdditionalPaths { get; set; }
}

View File

@@ -0,0 +1,47 @@
namespace LanMountainDesktop.Launcher.Services;
/// <summary>
/// 主程序发现选项
/// </summary>
public sealed class HostDiscoveryOptions
{
/// <summary>
/// 可执行文件名Windows 下自动添加 .exe
/// </summary>
public string ExecutableName { get; set; } = "LanMountainDesktop";
/// <summary>
/// 额外的搜索路径(支持通配符)
/// </summary>
public List<string> AdditionalSearchPaths { get; set; } = new();
/// <summary>
/// 是否递归搜索子目录
/// </summary>
public bool RecursiveSearch { get; set; } = false;
/// <summary>
/// 递归搜索的最大深度
/// </summary>
public int MaxRecursionDepth { get; set; } = 3;
/// <summary>
/// 环境变量名称,用于指定自定义路径
/// </summary>
public string? CustomPathEnvVar { get; set; } = "LMD_HOST_PATH";
/// <summary>
/// 配置文件路径(相对于 app root
/// </summary>
public string? ConfigFileName { get; set; } = "host-discovery.json";
/// <summary>
/// 是否优先使用开发模式配置
/// </summary>
public bool PreferDevModeConfig { get; set; } = true;
/// <summary>
/// 搜索超时(毫秒)
/// </summary>
public int SearchTimeoutMs { get; set; } = 5000;
}

View File

@@ -0,0 +1,6 @@
namespace LanMountainDesktop.Launcher.Services;
internal interface IOobeStep
{
Task RunAsync(CancellationToken cancellationToken);
}

View File

@@ -0,0 +1,11 @@
namespace LanMountainDesktop.Launcher.Services;
internal interface ISplashStageReporter
{
void Report(string stage, string message);
/// <summary>
/// 报告阶段和进度0-100
/// </summary>
void ReportStage(string stage, int progress);
}

View File

@@ -0,0 +1,192 @@
using System.Buffers;
using System.IO.Pipes;
using System.Text.Json;
using LanMountainDesktop.Shared.Contracts.Launcher;
namespace LanMountainDesktop.Launcher.Services.Ipc;
/// <summary>
/// Launcher IPC 服务端 - 接收主程序的启动进度报告
/// 采用持久连接 + 长度前缀协议,支持客户端在同一连接上多次发送消息。
/// 跨平台实现Windows 使用命名管道Linux/macOS 使用 Unix 域套接字
/// </summary>
public class LauncherIpcServer : IDisposable
{
private readonly CancellationTokenSource _cts = new();
private readonly Action<StartupProgressMessage> _onProgress;
private Task? _listenTask;
private NamedPipeServerStream? _currentPipe;
/// <summary>
/// 协议:每条消息以 4 字节小端 int32 长度前缀开头,后跟 UTF-8 JSON 正文。
/// 这在 Windows Message 模式和 Unix Byte 模式下均能可靠工作。
/// </summary>
private const int LengthPrefixSize = 4;
public LauncherIpcServer(Action<StartupProgressMessage> onProgress)
{
_onProgress = onProgress;
}
/// <summary>
/// 启动 IPC 服务端监听
/// </summary>
public void Start()
{
_listenTask = Task.Run(ListenLoopAsync, _cts.Token);
}
private async Task ListenLoopAsync()
{
while (!_cts.Token.IsCancellationRequested)
{
NamedPipeServerStream? pipe = null;
try
{
pipe = new NamedPipeServerStream(
LauncherIpcConstants.PipeName,
PipeDirection.In,
1,
PipeTransmissionMode.Byte);
_currentPipe = pipe;
await pipe.WaitForConnectionAsync(_cts.Token);
// 持久连接:在同一连接上循环读取多条消息,直到客户端断开
await ReadMessagesFromConnectionAsync(pipe, _cts.Token);
}
catch (OperationCanceledException)
{
break;
}
catch (IOException)
{
// 客户端断开连接,继续等待新连接
continue;
}
catch (ObjectDisposedException)
{
break;
}
catch (Exception ex)
{
Console.Error.WriteLine($"IPC listen error: {ex.Message}");
try
{
await Task.Delay(200, _cts.Token);
}
catch (OperationCanceledException)
{
break;
}
}
finally
{
try
{
pipe?.Dispose();
}
catch { }
if (ReferenceEquals(_currentPipe, pipe))
{
_currentPipe = null;
}
}
}
}
/// <summary>
/// 从已连接的管道中持续读取消息,直到连接断开或取消
/// </summary>
private async Task ReadMessagesFromConnectionAsync(NamedPipeServerStream pipe, CancellationToken cancellationToken)
{
var lengthBuffer = ArrayPool<byte>.Shared.Rent(LengthPrefixSize);
try
{
while (pipe.IsConnected && !cancellationToken.IsCancellationRequested)
{
// 1. 读取 4 字节长度前缀
var totalRead = 0;
while (totalRead < LengthPrefixSize)
{
var read = await pipe.ReadAsync(lengthBuffer.AsMemory(totalRead, LengthPrefixSize - totalRead), cancellationToken);
if (read == 0)
{
// 连接已关闭
return;
}
totalRead += read;
}
var payloadLength = BitConverter.ToInt32(lengthBuffer, 0);
if (payloadLength <= 0 || payloadLength > 1024 * 1024) // 最大 1MB 单条消息
{
// 无效长度,跳过此连接
return;
}
// 2. 读取消息正文
var payloadBuffer = ArrayPool<byte>.Shared.Rent(payloadLength);
try
{
totalRead = 0;
while (totalRead < payloadLength)
{
var read = await pipe.ReadAsync(payloadBuffer.AsMemory(totalRead, payloadLength - totalRead), cancellationToken);
if (read == 0)
{
return;
}
totalRead += read;
}
// 3. 反序列化并回调
var json = System.Text.Encoding.UTF8.GetString(payloadBuffer, 0, payloadLength);
var message = JsonSerializer.Deserialize(json, AppJsonContext.Default.StartupProgressMessage);
if (message is not null)
{
_onProgress(message);
}
}
catch (JsonException)
{
// 忽略解析错误,继续读取下一条消息
}
finally
{
ArrayPool<byte>.Shared.Return(payloadBuffer);
}
}
}
finally
{
ArrayPool<byte>.Shared.Return(lengthBuffer);
}
}
/// <summary>
/// 停止 IPC 服务端
/// </summary>
public void Stop()
{
_cts.Cancel();
try
{
_currentPipe?.Dispose();
}
catch { }
}
public void Dispose()
{
Stop();
_cts.Dispose();
try
{
_listenTask?.Wait(TimeSpan.FromSeconds(2));
}
catch { }
}
}

View File

@@ -0,0 +1,783 @@
using System.Diagnostics;
using Avalonia.Threading;
using LanMountainDesktop.Launcher.Models;
using LanMountainDesktop.Launcher.Services.Ipc;
using LanMountainDesktop.Launcher.Views;
using LanMountainDesktop.Shared.Contracts.Launcher;
namespace LanMountainDesktop.Launcher.Services;
internal sealed class LauncherFlowCoordinator
{
private static readonly string[] LauncherOnlyOptions =
[
"debug", "show-loading-details", "plugins-dir", "source", "result",
LauncherIpcConstants.LauncherPidEnvVar,
LauncherIpcConstants.PackageRootEnvVar,
LauncherIpcConstants.VersionEnvVar,
LauncherIpcConstants.CodenameEnvVar
];
private readonly CommandContext _context;
private readonly DeploymentLocator _deploymentLocator;
private readonly OobeStateService _oobeStateService;
private readonly UpdateEngineService _updateEngine;
private readonly PluginInstallerService _pluginInstallerService;
private readonly IReadOnlyList<IOobeStep> _oobeSteps;
public LauncherFlowCoordinator(
CommandContext context,
DeploymentLocator deploymentLocator,
OobeStateService oobeStateService,
UpdateEngineService updateEngine,
PluginInstallerService pluginInstallerService)
{
_context = context;
_deploymentLocator = deploymentLocator;
_oobeStateService = oobeStateService;
_updateEngine = updateEngine;
_pluginInstallerService = pluginInstallerService;
_oobeSteps = [new WelcomeOobeStep(_oobeStateService)];
}
public async Task<LauncherResult> RunAsync(SplashWindow? existingSplashWindow = null)
{
try
{
// 清理旧版本保留至少3个版本
_deploymentLocator.CleanupOldDeployments(minVersionsToKeep: 3);
// 检测老版本安装(首次运行时)
if (_oobeStateService.IsFirstRun())
{
var legacyInfo = LegacyVersionDetector.DetectLegacyInstallation();
if (legacyInfo != null)
{
var migrationResult = await ShowMigrationPromptAsync(legacyInfo);
// 无论用户选择什么,都继续启动流程
Console.WriteLine($"[LauncherFlowCoordinator] Migration prompt result: {migrationResult}");
}
}
// 使用传入的 Splash 窗口或创建新的
var splashWindow = existingSplashWindow ?? await Dispatcher.UIThread.InvokeAsync(() =>
{
var window = new SplashWindow();
window.Show();
return window;
});
var reporter = (ISplashStageReporter)splashWindow;
// 创建加载详情窗口(可选,用于显示详细加载状态)
LoadingDetailsWindow? loadingDetailsWindow = null;
if (_context.IsDebugMode || _context.GetOption("show-loading-details") == "true")
{
await Dispatcher.UIThread.InvokeAsync(() =>
{
loadingDetailsWindow = new LoadingDetailsWindow();
loadingDetailsWindow.Show();
});
}
// 跟踪主程序是否已就绪,就绪后自动关闭 Splash 窗口
var hostReadyTcs = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously);
// 加载状态管理
var loadingState = new LoadingStateMessage();
// 启动 IPC 服务端监听主程序进度
using var ipcServer = new LauncherIpcServer(msg =>
{
Dispatcher.UIThread.Post(() =>
{
try
{
// 更新加载状态
loadingState = loadingState with
{
Stage = msg.Stage,
OverallProgressPercent = msg.ProgressPercent,
Message = msg.Message,
Timestamp = DateTimeOffset.UtcNow
};
// 报告到 Splash 窗口
reporter.Report(msg.Stage.ToString().ToLower(), msg.Message ?? "");
// 更新加载详情窗口
loadingDetailsWindow?.UpdateLoadingState(loadingState);
// 主程序报告就绪后,关闭 Splash 窗口和加载详情窗口
if (msg.Stage == StartupStage.Ready)
{
if (splashWindow.IsVisible && splashWindow.IsLoaded)
{
splashWindow.Close();
}
loadingDetailsWindow?.Close();
hostReadyTcs.TrySetResult();
}
}
catch (Exception ex)
{
Console.Error.WriteLine($"[LauncherFlowCoordinator] Error in IPC callback: {ex.Message}");
}
});
});
ipcServer.Start();
try
{
// 检查并安装待处理的更新(主程序下载的)
reporter.Report("update", "检查更新...");
var updateResult = await _updateEngine.ApplyPendingUpdateAsync().ConfigureAwait(false);
if (!updateResult.Success)
{
return updateResult;
}
// 检查并安装待处理的插件更新
reporter.Report("plugins", "检查插件更新...");
var pluginsDir = _context.GetOption("plugins-dir")
?? Path.Combine(_deploymentLocator.GetAppRoot(), "plugins");
var queueResult = new PluginUpgradeQueueService(_pluginInstallerService).ApplyPendingUpgrades(pluginsDir);
if (!queueResult.Success)
{
return queueResult;
}
// OOBE首次运行引导
if (_oobeStateService.IsFirstRun())
{
await Dispatcher.UIThread.InvokeAsync(() => splashWindow.Hide());
foreach (var step in _oobeSteps)
{
await step.RunAsync(CancellationToken.None).ConfigureAwait(false);
}
await Dispatcher.UIThread.InvokeAsync(() => splashWindow.Show());
}
// 启动主程序
reporter.Report("launch", "正在启动...");
var (hostResult, hostProcess) = await LaunchHostWithIpcAsync(splashWindow);
if (!hostResult.Success)
{
return hostResult;
}
// 等待主程序进程退出。Launcher 作为后台守护进程保持运行,
// 维持 IPC 管道服务端供主程序报告启动进度。
if (hostProcess is not null)
{
var processExitTask = hostProcess.WaitForExitAsync();
// 等待主程序就绪或进程退出(取先发生者)
// 30 秒超时,宿主端有 10 秒兜底机制确保 Ready 信号发送
var readyOrTimeoutOrExit = Task.WhenAny(
hostReadyTcs.Task,
processExitTask,
Task.Delay(TimeSpan.FromSeconds(30)));
var completedTask = await readyOrTimeoutOrExit;
// Host process exited before reporting Ready.
if (completedTask == processExitTask)
{
var exitCode = hostProcess.ExitCode;
Console.Error.WriteLine($"[LauncherFlowCoordinator] Host process exited before Ready. ExitCode={exitCode}.");
var recoveryResult = await TryRecoverFromEarlyHostExitAsync(
exitCode,
hostReadyTcs,
splashWindow,
loadingDetailsWindow).ConfigureAwait(false);
if (recoveryResult is not null)
{
return recoveryResult;
}
// Close Splash window for unrecoverable early exits.
await Dispatcher.UIThread.InvokeAsync(() =>
{
try
{
if (splashWindow.IsVisible && splashWindow.IsLoaded)
{
splashWindow.Close();
}
}
catch (Exception ex)
{
Console.Error.WriteLine($"[LauncherFlowCoordinator] Error closing splash window: {ex.Message}");
}
});
return new LauncherResult
{
Success = false,
Stage = "launch",
Code = "host_crashed",
Message = $"主程序异常退出,退出代码: {exitCode}"
};
}
// 如果 Splash 窗口仍然打开(超时情况),关闭它
if (splashWindow.IsVisible)
{
Console.WriteLine("[LauncherFlowCoordinator] Timeout waiting for Ready signal, closing splash window...");
await Dispatcher.UIThread.InvokeAsync(() =>
{
try
{
if (splashWindow.IsVisible && splashWindow.IsLoaded)
{
splashWindow.Close();
}
}
catch (Exception ex)
{
Console.Error.WriteLine($"[LauncherFlowCoordinator] Error closing splash window on timeout: {ex.Message}");
}
});
}
// 继续等待主程序进程退出(如果它还在运行)
if (!hostProcess.HasExited)
{
await processExitTask;
}
}
else
{
// 如果无法获取进程引用,退回到有限等待
await Task.Delay(TimeSpan.FromSeconds(30));
}
return new LauncherResult
{
Success = true,
Stage = "exit",
Code = "ok",
Message = "Launcher completed successfully."
};
}
finally
{
// Splash 窗口可能已由 IPC Ready 回调关闭,这里做安全清理
await Dispatcher.UIThread.InvokeAsync(() =>
{
try
{
if (splashWindow.IsVisible && splashWindow.IsLoaded)
{
splashWindow.Close();
Console.WriteLine("[LauncherFlowCoordinator] Splash window closed in finally block");
}
}
catch (Exception ex)
{
Console.Error.WriteLine($"[LauncherFlowCoordinator] Error closing splash window in finally: {ex.Message}");
}
});
}
}
catch (Exception ex)
{
return new LauncherResult
{
Success = false,
Stage = "launch",
Code = "exception",
Message = ex.Message,
ErrorMessage = ex.ToString()
};
}
}
private async Task<LauncherResult?> TryRecoverFromEarlyHostExitAsync(
int exitCode,
TaskCompletionSource hostReadyTcs,
SplashWindow splashWindow,
LoadingDetailsWindow? loadingDetailsWindow)
{
if (exitCode == HostExitCodes.SecondaryActivationSucceeded)
{
Console.WriteLine("[LauncherFlowCoordinator] Host redirected activation to an existing primary instance.");
await CloseWindowsAsync(splashWindow, loadingDetailsWindow).ConfigureAwait(false);
return new LauncherResult
{
Success = true,
Stage = "launch",
Code = "activated_existing_instance",
Message = "Detected existing running instance and activation was acknowledged."
};
}
if (exitCode is not HostExitCodes.SecondaryActivationFailed and not HostExitCodes.RestartLockNotAcquired)
{
return null;
}
Console.Error.WriteLine(
$"[LauncherFlowCoordinator] Activation handshake failed with exit code {exitCode}. Retrying explicit activation once...");
var (retryLaunchResult, retryProcess) = await LaunchHostWithIpcAsync(splashWindow).ConfigureAwait(false);
if (!retryLaunchResult.Success)
{
return retryLaunchResult;
}
if (retryProcess is null)
{
return new LauncherResult
{
Success = false,
Stage = "launch",
Code = "activation_retry_start_failed",
Message = "Explicit activation retry failed because no host process was created."
};
}
Console.WriteLine($"[LauncherFlowCoordinator] Explicit activation retry started. RetryPid={retryProcess.Id}.");
var retryExitTask = retryProcess.WaitForExitAsync();
var retryCompleted = await Task.WhenAny(
hostReadyTcs.Task,
retryExitTask,
Task.Delay(TimeSpan.FromSeconds(15))).ConfigureAwait(false);
if (retryCompleted == hostReadyTcs.Task)
{
Console.WriteLine("[LauncherFlowCoordinator] Host reported Ready after explicit activation retry.");
await CloseWindowsAsync(splashWindow, loadingDetailsWindow).ConfigureAwait(false);
return new LauncherResult
{
Success = true,
Stage = "launch",
Code = "activation_retry_ready",
Message = "Explicit activation retry succeeded and host reported Ready."
};
}
if (retryCompleted == retryExitTask)
{
var retryExitCode = retryProcess.ExitCode;
if (retryExitCode == HostExitCodes.SecondaryActivationSucceeded)
{
await CloseWindowsAsync(splashWindow, loadingDetailsWindow).ConfigureAwait(false);
return new LauncherResult
{
Success = true,
Stage = "launch",
Code = "activation_retry_redirected",
Message = "Explicit activation retry redirected to the existing primary instance."
};
}
return new LauncherResult
{
Success = false,
Stage = "launch",
Code = "activation_retry_failed",
Message = $"Explicit activation retry failed. ExitCode={retryExitCode}. 请结束残留后台进程后重试。"
};
}
return new LauncherResult
{
Success = false,
Stage = "launch",
Code = "activation_retry_timeout",
Message = "Explicit activation retry timed out before host became ready. 请结束残留后台进程后重试。"
};
}
private static async Task CloseWindowsAsync(SplashWindow splashWindow, LoadingDetailsWindow? loadingDetailsWindow)
{
await Dispatcher.UIThread.InvokeAsync(() =>
{
try
{
if (splashWindow.IsVisible && splashWindow.IsLoaded)
{
splashWindow.Close();
}
}
catch (Exception ex)
{
Console.Error.WriteLine($"[LauncherFlowCoordinator] Failed to close splash window: {ex.Message}");
}
try
{
if (loadingDetailsWindow is not null && loadingDetailsWindow.IsVisible)
{
loadingDetailsWindow.Close();
}
}
catch (Exception ex)
{
Console.Error.WriteLine($"[LauncherFlowCoordinator] Failed to close loading details window: {ex.Message}");
}
});
}
private async Task<(LauncherResult Result, Process? Process)> LaunchHostWithIpcAsync(SplashWindow? splashWindow = null, string? customHostPath = null)
{
// 优先使用自定义路径(调试模式选择的路径)
var hostPath = customHostPath ?? _deploymentLocator.ResolveHostExecutablePath();
if (string.IsNullOrWhiteSpace(hostPath))
{
// 关闭 Splash 窗口
// 显示错误窗口而不是直接退出
var (errorResult, selectedPath) = await ShowHostNotFoundErrorAsync();
if (errorResult == ErrorWindowResult.Retry)
{
// 用户选择重试,如果有选择路径则使用,否则重新尝试
if (!string.IsNullOrWhiteSpace(selectedPath))
{
return await LaunchHostWithIpcAsync(splashWindow, selectedPath);
}
return await LaunchHostWithIpcAsync(splashWindow);
}
// 用户选择退出
return (new LauncherResult
{
Success = false,
Stage = "launchHost",
Code = "host_not_found",
Message = "LanMountainDesktop host executable not found."
}, null);
}
if (OperatingSystem.IsLinux() || OperatingSystem.IsMacOS())
{
EnsureExecutable(hostPath);
}
var hostWorkingDir = Path.GetDirectoryName(hostPath) ?? _deploymentLocator.GetAppRoot();
var versionInfo = _deploymentLocator.GetVersionInfo();
// 构建命令行参数:转发用户参数 + IPC 环境信息通过命令行传递
// UseShellExecute = true 确保 Shell 启动子进程,使其正确关联到交互式桌面窗口站(WinSta0)
// 避免子进程窗口创建成功但不可见的问题。
var arguments = new System.Text.StringBuilder();
// 转发命令行参数给主程序(排除 Launcher 自己的命令和选项)
// 只过滤 Launcher 专属的选项,保留宿主程序需要的参数(如 --restart-parent-pid
foreach (var arg in _context.RawArgs)
{
if (arg == _context.Command || arg == _context.SubCommand)
continue;
if (arg.StartsWith("--"))
{
var key = arg[2..];
var equalsIndex = key.IndexOf('=');
if (equalsIndex >= 0) key = key[..equalsIndex];
if (LauncherOnlyOptions.Contains(key, StringComparer.OrdinalIgnoreCase))
continue;
}
if (arguments.Length > 0) arguments.Append(' ');
arguments.Append(QuoteArgument(arg));
}
// 通过命令行参数传递 IPC 连接信息UseShellExecute=true 时不支持 EnvironmentVariables
if (arguments.Length > 0) arguments.Append(' ');
arguments.Append($"--{LauncherIpcConstants.LauncherPidEnvVar}={Environment.ProcessId}");
arguments.Append($" --{LauncherIpcConstants.PackageRootEnvVar}={QuoteArgument(_deploymentLocator.GetAppRoot())}");
arguments.Append($" --{LauncherIpcConstants.VersionEnvVar}={versionInfo.Version}");
arguments.Append($" --{LauncherIpcConstants.CodenameEnvVar}={versionInfo.Codename}");
var processStartInfo = new ProcessStartInfo
{
FileName = hostPath,
UseShellExecute = true,
WorkingDirectory = hostWorkingDir,
Arguments = arguments.ToString()
};
// 同时设置环境变量作为备选(当 UseShellExecute=true 时 EnvironmentVariables 仍会被子进程继承)
processStartInfo.EnvironmentVariables[LauncherIpcConstants.LauncherPidEnvVar] =
Environment.ProcessId.ToString();
processStartInfo.EnvironmentVariables[LauncherIpcConstants.PackageRootEnvVar] =
_deploymentLocator.GetAppRoot();
processStartInfo.EnvironmentVariables[LauncherIpcConstants.VersionEnvVar] = versionInfo.Version;
processStartInfo.EnvironmentVariables[LauncherIpcConstants.CodenameEnvVar] = versionInfo.Codename;
var hostProcess = Process.Start(processStartInfo);
Console.WriteLine(
$"[LauncherFlowCoordinator] Host launch requested. Path='{hostPath}'; WorkingDir='{hostWorkingDir}'; " +
$"Pid={(hostProcess is null ? -1 : hostProcess.Id)}; Args='{processStartInfo.Arguments}'.");
return (new LauncherResult
{
Success = true,
Stage = "launchHost",
Code = "ok",
Message = "Host launched."
}, hostProcess);
}
/// <summary>
/// 显示找不到主程序的错误窗口
/// </summary>
private async Task<(ErrorWindowResult Result, string? CustomPath)> ShowHostNotFoundErrorAsync()
{
ErrorWindow? errorWindow = null;
// 在 UI 线程创建并显示错误窗口
await Dispatcher.UIThread.InvokeAsync(() =>
{
try
{
errorWindow = new ErrorWindow();
errorWindow.SetErrorMessage("找不到阑山桌面应用程序。");
errorWindow.Show();
Console.WriteLine("[LauncherFlowCoordinator] ErrorWindow shown for host not found");
}
catch (Exception ex)
{
Console.Error.WriteLine($"[LauncherFlowCoordinator] Failed to show ErrorWindow: {ex.Message}");
}
});
if (errorWindow is null)
{
Console.Error.WriteLine("[LauncherFlowCoordinator] ErrorWindow is null, cannot wait for choice");
return (ErrorWindowResult.Exit, null);
}
// 等待用户选择
ErrorWindowResult result;
string? customPath;
try
{
result = await errorWindow.WaitForChoiceAsync();
customPath = errorWindow.GetCustomHostPath();
Console.WriteLine($"[LauncherFlowCoordinator] ErrorWindow result: {result}, customPath: {customPath != null}");
}
catch (Exception ex)
{
Console.Error.WriteLine($"[LauncherFlowCoordinator] Error waiting for choice: {ex.Message}");
result = ErrorWindowResult.Exit;
customPath = null;
}
// 安全关闭错误窗口
await Dispatcher.UIThread.InvokeAsync(() =>
{
try
{
if (errorWindow.IsVisible && errorWindow.IsLoaded)
{
errorWindow.Close();
Console.WriteLine("[LauncherFlowCoordinator] ErrorWindow closed successfully");
}
}
catch (Exception ex)
{
Console.Error.WriteLine($"[LauncherFlowCoordinator] Error closing ErrorWindow: {ex.Message}");
}
});
return (result, customPath);
}
/// <summary>
/// 显示迁移提示窗口
/// </summary>
private async Task<MigrationResult> ShowMigrationPromptAsync(LegacyVersionInfo legacyInfo)
{
MigrationPromptWindow? migrationWindow = null;
// 在 UI 线程创建并显示迁移提示窗口
await Dispatcher.UIThread.InvokeAsync(() =>
{
try
{
migrationWindow = new MigrationPromptWindow();
migrationWindow.SetLegacyInfo(legacyInfo);
migrationWindow.Show();
Console.WriteLine("[LauncherFlowCoordinator] MigrationPromptWindow shown");
}
catch (Exception ex)
{
Console.Error.WriteLine($"[LauncherFlowCoordinator] Failed to show MigrationPromptWindow: {ex.Message}");
}
});
if (migrationWindow is null)
{
Console.Error.WriteLine("[LauncherFlowCoordinator] MigrationPromptWindow is null, skipping migration prompt");
return MigrationResult.Skipped;
}
// 等待用户选择
MigrationResult result;
try
{
result = await migrationWindow.WaitForChoiceAsync();
Console.WriteLine($"[LauncherFlowCoordinator] MigrationPromptWindow result: {result}");
}
catch (Exception ex)
{
Console.Error.WriteLine($"[LauncherFlowCoordinator] Error waiting for migration choice: {ex.Message}");
result = MigrationResult.Skipped;
}
// 安全关闭窗口
await Dispatcher.UIThread.InvokeAsync(() =>
{
try
{
if (migrationWindow.IsVisible && migrationWindow.IsLoaded)
{
migrationWindow.Close();
Console.WriteLine("[LauncherFlowCoordinator] MigrationPromptWindow closed successfully");
}
}
catch (Exception ex)
{
Console.Error.WriteLine($"[LauncherFlowCoordinator] Error closing MigrationPromptWindow: {ex.Message}");
}
});
return result;
}
private static string QuoteArgument(string value)
{
if (string.IsNullOrEmpty(value))
{
return "\"\"";
}
if (!value.Contains('"') && !value.Contains(' ') && !value.Contains('\t'))
{
return value;
}
var builder = new System.Text.StringBuilder();
builder.Append('"');
foreach (var ch in value)
{
if (ch == '"')
{
builder.Append("\\\"");
}
else
{
builder.Append(ch);
}
}
builder.Append('"');
return builder.ToString();
}
private static void EnsureExecutable(string path)
{
if (OperatingSystem.IsWindows())
{
return;
}
try
{
var mode = File.GetUnixFileMode(path);
mode |= UnixFileMode.UserExecute | UnixFileMode.GroupExecute | UnixFileMode.OtherExecute;
File.SetUnixFileMode(path, mode);
}
catch
{
}
}
private sealed class WelcomeOobeStep : IOobeStep
{
private readonly OobeStateService _stateService;
public WelcomeOobeStep(OobeStateService stateService)
{
_stateService = stateService;
}
public async Task RunAsync(CancellationToken cancellationToken)
{
OobeWindow? window = null;
try
{
window = await Dispatcher.UIThread.InvokeAsync(() =>
{
try
{
var oobeWindow = new OobeWindow();
oobeWindow.Show();
Console.WriteLine("[WelcomeOobeStep] OOBE window shown");
return oobeWindow;
}
catch (Exception ex)
{
Console.Error.WriteLine($"[WelcomeOobeStep] Failed to show OOBE window: {ex.Message}");
return null;
}
});
if (window is null)
{
Console.Error.WriteLine("[WelcomeOobeStep] OOBE window is null, skipping OOBE");
_stateService.MarkCompleted();
return;
}
using var _ = cancellationToken.Register(() =>
{
try
{
if (window.IsVisible && window.IsLoaded)
{
window.Close();
}
}
catch (Exception ex)
{
Console.Error.WriteLine($"[WelcomeOobeStep] Error closing OOBE window on cancel: {ex.Message}");
}
});
await window.WaitForEnterAsync().ConfigureAwait(false);
Console.WriteLine("[WelcomeOobeStep] OOBE completed by user");
_stateService.MarkCompleted();
}
finally
{
if (window is not null)
{
await Dispatcher.UIThread.InvokeAsync(() =>
{
try
{
if (window.IsVisible && window.IsLoaded)
{
window.Close();
Console.WriteLine("[WelcomeOobeStep] OOBE window closed in finally");
}
}
catch (Exception ex)
{
Console.Error.WriteLine($"[WelcomeOobeStep] Error closing OOBE window in finally: {ex.Message}");
}
});
}
}
}
}
}

View File

@@ -0,0 +1,341 @@
using System.Diagnostics;
using Microsoft.Win32;
namespace LanMountainDesktop.Launcher.Services;
/// <summary>
/// 老版本检测器 - 检测 0.8.x 及更早的单应用模式安装
/// </summary>
internal sealed class LegacyVersionDetector
{
private const string LegacyAppName = "LanMountainDesktop";
private const string LegacyExeName = "LanMountainDesktop.exe";
/// <summary>
/// 检测是否存在老版本安装
/// </summary>
public static LegacyVersionInfo? DetectLegacyInstallation()
{
// 1. 检查注册表(安装版)
var registryInfo = DetectFromRegistry();
if (registryInfo != null)
{
return registryInfo;
}
// 2. 检查常见安装目录
var commonPaths = DetectFromCommonPaths();
if (commonPaths != null)
{
return commonPaths;
}
// 3. 检查便携版位置
var portableInfo = DetectPortableInstallation();
if (portableInfo != null)
{
return portableInfo;
}
return null;
}
/// <summary>
/// 从注册表检测安装信息
/// </summary>
private static LegacyVersionInfo? DetectFromRegistry()
{
try
{
// 检查 HKLM\Software\Microsoft\Windows\CurrentVersion\Uninstall
using var key = Registry.LocalMachine.OpenSubKey(
@$"Software\Microsoft\Windows\CurrentVersion\Uninstall\{LegacyAppName}");
if (key != null)
{
var installLocation = key.GetValue("InstallLocation") as string;
var displayVersion = key.GetValue("DisplayVersion") as string;
var uninstallString = key.GetValue("UninstallString") as string;
if (!string.IsNullOrWhiteSpace(installLocation) &&
File.Exists(Path.Combine(installLocation, LegacyExeName)))
{
return new LegacyVersionInfo
{
Version = displayVersion ?? "0.8.x",
InstallPath = installLocation,
UninstallCommand = uninstallString,
InstallType = LegacyInstallType.Registry
};
}
}
// 检查 HKCU用户级安装
using var userKey = Registry.CurrentUser.OpenSubKey(
@$"Software\Microsoft\Windows\CurrentVersion\Uninstall\{LegacyAppName}");
if (userKey != null)
{
var installLocation = userKey.GetValue("InstallLocation") as string;
var displayVersion = userKey.GetValue("DisplayVersion") as string;
var uninstallString = userKey.GetValue("UninstallString") as string;
if (!string.IsNullOrWhiteSpace(installLocation) &&
File.Exists(Path.Combine(installLocation, LegacyExeName)))
{
return new LegacyVersionInfo
{
Version = displayVersion ?? "0.8.x",
InstallPath = installLocation,
UninstallCommand = uninstallString,
InstallType = LegacyInstallType.Registry
};
}
}
}
catch (Exception ex)
{
Console.WriteLine($"[LegacyVersionDetector] Registry detection failed: {ex.Message}");
}
return null;
}
/// <summary>
/// 从常见安装路径检测
/// </summary>
private static LegacyVersionInfo? DetectFromCommonPaths()
{
var commonPaths = new[]
{
Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.ProgramFiles), LegacyAppName),
Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.ProgramFilesX86), LegacyAppName),
Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData), LegacyAppName),
Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.ApplicationData), LegacyAppName),
};
foreach (var path in commonPaths)
{
try
{
if (Directory.Exists(path))
{
// 检查是否存在老版本的特征文件(没有 app-* 目录)
var exePath = Path.Combine(path, LegacyExeName);
var hasAppDirs = Directory.GetDirectories(path, "app-*").Length > 0;
if (File.Exists(exePath) && !hasAppDirs)
{
// 尝试读取版本信息
var version = TryGetFileVersion(exePath);
return new LegacyVersionInfo
{
Version = version ?? "0.8.x",
InstallPath = path,
UninstallCommand = FindUninstaller(path),
InstallType = LegacyInstallType.CommonPath
};
}
}
}
catch (Exception ex)
{
Console.WriteLine($"[LegacyVersionDetector] Path detection failed for {path}: {ex.Message}");
}
}
return null;
}
/// <summary>
/// 检测便携版安装
/// </summary>
private static LegacyVersionInfo? DetectPortableInstallation()
{
try
{
// 检查启动器所在目录的父目录(便携版常见布局)
var launcherDir = AppContext.BaseDirectory;
var parentDir = Path.GetFullPath(Path.Combine(launcherDir, ".."));
if (Directory.Exists(parentDir))
{
var exePath = Path.Combine(parentDir, LegacyExeName);
var hasAppDirs = Directory.GetDirectories(parentDir, "app-*").Length > 0;
// 如果存在 exe 且没有 app-* 目录,可能是老版本
if (File.Exists(exePath) && !hasAppDirs)
{
var version = TryGetFileVersion(exePath);
// 检查是否真的是老版本(通过文件版本或特定标记)
if (IsLegacyVersion(version))
{
return new LegacyVersionInfo
{
Version = version ?? "0.8.x",
InstallPath = parentDir,
UninstallCommand = null, // 便携版没有卸载程序
InstallType = LegacyInstallType.Portable
};
}
}
}
}
catch (Exception ex)
{
Console.WriteLine($"[LegacyVersionDetector] Portable detection failed: {ex.Message}");
}
return null;
}
/// <summary>
/// 查找卸载程序
/// </summary>
private static string? FindUninstaller(string installPath)
{
try
{
// 常见的卸载程序命名
var uninstallerNames = new[] { "unins000.exe", "uninstall.exe", "Uninstall.exe" };
foreach (var name in uninstallerNames)
{
var path = Path.Combine(installPath, name);
if (File.Exists(path))
{
return path;
}
}
}
catch { }
return null;
}
/// <summary>
/// 获取文件版本
/// </summary>
private static string? TryGetFileVersion(string filePath)
{
try
{
var versionInfo = FileVersionInfo.GetVersionInfo(filePath);
return versionInfo.FileVersion;
}
catch
{
return null;
}
}
/// <summary>
/// 判断是否为老版本(版本号 < 1.0.0
/// </summary>
private static bool IsLegacyVersion(string? version)
{
if (string.IsNullOrWhiteSpace(version))
{
return true; // 无法确定版本时,保守认为是老版本
}
if (Version.TryParse(version.Split(' ')[0], out var v))
{
return v.Major < 1;
}
return true;
}
/// <summary>
/// 打开卸载界面
/// </summary>
public static void OpenUninstallInterface(LegacyVersionInfo info)
{
try
{
if (!string.IsNullOrWhiteSpace(info.UninstallCommand))
{
// 有卸载命令,直接执行
var parts = info.UninstallCommand.Split(new[] { ' ' }, 2);
var fileName = parts[0].Trim('"');
var arguments = parts.Length > 1 ? parts[1] : "";
Process.Start(new ProcessStartInfo
{
FileName = fileName,
Arguments = arguments,
UseShellExecute = true,
Verb = "runas" // 请求管理员权限
});
}
else
{
// 没有卸载命令,打开系统卸载面板
Process.Start(new ProcessStartInfo
{
FileName = "appwiz.cpl",
UseShellExecute = true
});
}
}
catch (Exception ex)
{
Console.Error.WriteLine($"[LegacyVersionDetector] Failed to open uninstall: {ex.Message}");
// 兜底:打开系统卸载面板
try
{
Process.Start(new ProcessStartInfo
{
FileName = "appwiz.cpl",
UseShellExecute = true
});
}
catch { }
}
}
/// <summary>
/// 在资源管理器中显示老版本位置
/// </summary>
public static void ShowInExplorer(string path)
{
try
{
Process.Start(new ProcessStartInfo
{
FileName = "explorer.exe",
Arguments = $"/select,\"{path}\"",
UseShellExecute = false
});
}
catch (Exception ex)
{
Console.Error.WriteLine($"[LegacyVersionDetector] Failed to show in explorer: {ex.Message}");
}
}
}
/// <summary>
/// 老版本信息
/// </summary>
public class LegacyVersionInfo
{
public string Version { get; set; } = "0.8.x";
public string InstallPath { get; set; } = "";
public string? UninstallCommand { get; set; }
public LegacyInstallType InstallType { get; set; }
}
/// <summary>
/// 老版本安装类型
/// </summary>
public enum LegacyInstallType
{
Registry, // 注册表安装版
CommonPath, // 常见路径安装
Portable // 便携版
}

View File

@@ -0,0 +1,138 @@
using System.Text;
namespace LanMountainDesktop.Launcher.Services;
/// <summary>
/// 简单的日志记录器 - 同时输出到控制台和文件
/// </summary>
internal static class Logger
{
private static readonly object _lock = new();
private static string? _logFilePath;
private static bool _initialized;
/// <summary>
/// 初始化日志记录器
/// </summary>
public static void Initialize()
{
if (_initialized)
{
return;
}
try
{
var logDir = GetLogDirectory();
if (!string.IsNullOrEmpty(logDir))
{
Directory.CreateDirectory(logDir);
var timestamp = DateTime.Now.ToString("yyyyMMdd_HHmmss");
_logFilePath = Path.Combine(logDir, $"launcher_{timestamp}.log");
Console.WriteLine($"[Logger] Log file initialized: {_logFilePath}");
}
}
catch (Exception ex)
{
Console.Error.WriteLine($"[Logger] Failed to initialize log file: {ex.Message}");
}
_initialized = true;
}
/// <summary>
/// 获取日志文件路径
/// </summary>
public static string? GetLogFilePath()
{
return _logFilePath;
}
/// <summary>
/// 获取日志目录
/// </summary>
private static string? GetLogDirectory()
{
try
{
var appData = Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData);
if (!string.IsNullOrEmpty(appData))
{
return Path.Combine(appData, "LanMountainDesktop", ".launcher", "logs");
}
}
catch
{
}
try
{
var launcherDir = AppContext.BaseDirectory;
return Path.Combine(launcherDir, ".launcher", "logs");
}
catch
{
}
return null;
}
/// <summary>
/// 记录信息日志
/// </summary>
public static void Info(string message)
{
WriteLog("INFO", message);
}
/// <summary>
/// 记录警告日志
/// </summary>
public static void Warn(string message)
{
WriteLog("WARN", message);
}
/// <summary>
/// 记录错误日志
/// </summary>
public static void Error(string message)
{
WriteLog("ERROR", message);
}
/// <summary>
/// 记录错误日志(带异常)
/// </summary>
public static void Error(string message, Exception exception)
{
WriteLog("ERROR", $"{message}\n{exception}");
}
/// <summary>
/// 写入日志
/// </summary>
private static void WriteLog(string level, string message)
{
var timestamp = DateTime.Now.ToString("yyyy-MM-dd HH:mm:ss.fff");
var logLine = $"[{timestamp}] [{level}] {message}";
Console.WriteLine(logLine);
if (string.IsNullOrEmpty(_logFilePath))
{
return;
}
try
{
lock (_lock)
{
File.AppendAllText(_logFilePath, logLine + Environment.NewLine, Encoding.UTF8);
}
}
catch
{
}
}
}

View File

@@ -0,0 +1,104 @@
namespace LanMountainDesktop.Launcher.Services;
internal sealed class OobeStateService
{
private readonly string _markerPath;
public OobeStateService(string appRoot)
{
// 优先使用 LocalApplicationData用户目录普通用户一定有权限
string? stateDir = null;
Exception? lastException = null;
// 策略1: LocalApplicationData首选用户目录普通用户一定有写权限
try
{
var appDataDir = Path.Combine(
Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData),
"LanMountainDesktop");
stateDir = Path.Combine(appDataDir, ".launcher", "state");
Directory.CreateDirectory(stateDir);
Console.WriteLine($"[OobeStateService] Using LocalApplicationData: {stateDir}");
}
catch (Exception ex)
{
lastException = ex;
Console.Error.WriteLine($"[OobeStateService] LocalApplicationData failed: {ex.Message}");
stateDir = null;
}
// 策略2: 如果LocalApplicationData不行使用用户的临时目录
if (stateDir == null)
{
try
{
var tempDir = Path.Combine(Path.GetTempPath(), "LanMountainDesktop", ".launcher", "state");
Directory.CreateDirectory(tempDir);
stateDir = tempDir;
Console.WriteLine($"[OobeStateService] Using TempPath: {stateDir}");
}
catch (Exception ex)
{
lastException = ex;
Console.Error.WriteLine($"[OobeStateService] TempPath failed: {ex.Message}");
stateDir = null;
}
}
// 策略3: 最后的兜底使用当前用户的应用程序数据目录和Launcher同目录
if (stateDir == null)
{
try
{
var launcherDir = AppContext.BaseDirectory;
stateDir = Path.Combine(launcherDir, ".launcher", "state");
Directory.CreateDirectory(stateDir);
Console.WriteLine($"[OobeStateService] Using Launcher directory: {stateDir}");
}
catch (Exception ex)
{
lastException = ex;
Console.Error.WriteLine($"[OobeStateService] All strategies failed! Last error: {ex.Message}");
// 如果所有策略都失败,抛出异常让上层处理
throw new InvalidOperationException("无法创建 OOBE 状态存储目录失败", lastException);
}
}
_markerPath = Path.Combine(stateDir, "first_run_completed");
Console.WriteLine($"[OobeStateService] Initialized successfully, marker path: {_markerPath}");
}
public bool IsFirstRun()
{
try
{
return !File.Exists(_markerPath);
}
catch (Exception ex)
{
Console.Error.WriteLine($"[OobeStateService] Failed to check first run: {ex.Message}");
// 如果无法检查默认视为首次运行确保OOBE能显示
return true;
}
}
public void MarkCompleted()
{
try
{
var dir = Path.GetDirectoryName(_markerPath);
if (!string.IsNullOrWhiteSpace(dir))
{
Directory.CreateDirectory(dir);
}
File.WriteAllText(_markerPath, DateTimeOffset.UtcNow.ToString("O"));
Console.WriteLine("[OobeStateService] Marked first run as completed");
}
catch (Exception ex)
{
Console.Error.WriteLine($"[OobeStateService] Failed to mark completed: {ex.Message}");
// 如果无法写入也没关系下次启动还会显示OOBE
}
}
}

View File

@@ -0,0 +1,226 @@
using System.IO.Compression;
using System.Text.Json;
using LanMountainDesktop.Launcher.Models;
namespace LanMountainDesktop.Launcher.Services;
/// <summary>
/// 插件安装服务 - 简化版,不依赖 PluginSdk
/// </summary>
internal sealed class PluginInstallerService
{
private const string ManifestFileName = "manifest.json";
private const string PackageFileExtension = ".lmdp";
private const string RuntimeDirectoryName = "runtime";
private static readonly TimeSpan[] RetryDelays =
[
TimeSpan.FromMilliseconds(120),
TimeSpan.FromMilliseconds(250),
TimeSpan.FromMilliseconds(500)
];
public LauncherResult InstallPackage(string sourcePath, string pluginsDirectory)
{
var fullSourcePath = Path.GetFullPath(sourcePath);
var fullPluginsDirectory = Path.GetFullPath(pluginsDirectory);
if (!File.Exists(fullSourcePath))
{
throw new FileNotFoundException($"Plugin package '{fullSourcePath}' was not found.", fullSourcePath);
}
var manifest = ReadManifestFromPackage(fullSourcePath);
Directory.CreateDirectory(fullPluginsDirectory);
var destinationPath = Path.Combine(fullPluginsDirectory, BuildInstalledPackageFileName(manifest.Id));
var stagingPath = destinationPath + ".incoming";
DeleteFileWithRetry(stagingPath);
CopyWithRetry(fullSourcePath, stagingPath, overwrite: true);
RemoveExistingPluginPackages(fullPluginsDirectory, manifest.Id, destinationPath, stagingPath);
MoveWithOverwriteRetry(stagingPath, destinationPath);
return new LauncherResult
{
Success = true,
Stage = "plugin.install",
Code = "ok",
Message = "Plugin installed.",
InstalledPackagePath = destinationPath,
ManifestId = manifest.Id,
ManifestName = manifest.Name
};
}
public PluginManifest ReadManifestFromPackage(string packagePath)
{
using var archive = ZipFile.OpenRead(packagePath);
var entries = archive.Entries
.Where(entry => string.Equals(entry.Name, ManifestFileName, StringComparison.OrdinalIgnoreCase))
.ToArray();
if (entries.Length == 0)
{
throw new InvalidOperationException(
$"Plugin package '{packagePath}' does not contain '{ManifestFileName}'.");
}
if (entries.Length > 1)
{
throw new InvalidOperationException(
$"Plugin package '{packagePath}' contains multiple '{ManifestFileName}' files.");
}
using var stream = entries[0].Open();
using var reader = new StreamReader(stream);
var json = reader.ReadToEnd();
var manifest = JsonSerializer.Deserialize(json, AppJsonContext.Default.PluginManifest);
if (manifest == null)
{
throw new InvalidOperationException($"Failed to deserialize manifest from '{packagePath}'.");
}
return manifest;
}
private void RemoveExistingPluginPackages(string pluginsDirectory, string pluginId, string destinationPath, string stagingPath)
{
var runtimeRootDirectory = EnsureTrailingSeparator(Path.Combine(Path.GetFullPath(pluginsDirectory), RuntimeDirectoryName));
var pendingDeletionDir = Path.Combine(pluginsDirectory, ".pending-deletions");
Directory.CreateDirectory(pendingDeletionDir);
foreach (var existingPackagePath in Directory
.EnumerateFiles(pluginsDirectory, "*" + PackageFileExtension, SearchOption.AllDirectories)
.Select(Path.GetFullPath)
.Where(path => !path.StartsWith(runtimeRootDirectory, StringComparison.OrdinalIgnoreCase)))
{
try
{
if (string.Equals(existingPackagePath, Path.GetFullPath(destinationPath), StringComparison.OrdinalIgnoreCase) ||
string.Equals(existingPackagePath, Path.GetFullPath(stagingPath), StringComparison.OrdinalIgnoreCase))
{
continue;
}
var existingManifest = ReadManifestFromPackage(existingPackagePath);
if (!string.Equals(existingManifest.Id, pluginId, StringComparison.OrdinalIgnoreCase))
{
continue;
}
TryRemoveExistingPackage(existingPackagePath, pendingDeletionDir);
}
catch
{
}
}
CleanupPendingDeletions(pendingDeletionDir);
}
private void TryRemoveExistingPackage(string existingPackagePath, string pendingDeletionDir)
{
try
{
DeleteFileWithRetry(existingPackagePath);
}
catch (IOException)
{
var fileName = Path.GetFileName(existingPackagePath);
var pendingPath = Path.Combine(pendingDeletionDir, $"{fileName}.{Guid.NewGuid():N}.pending");
File.Move(existingPackagePath, pendingPath);
}
}
private static void CleanupPendingDeletions(string pendingDeletionDir)
{
if (!Directory.Exists(pendingDeletionDir))
{
return;
}
foreach (var pendingFile in Directory.EnumerateFiles(pendingDeletionDir, "*.pending"))
{
try
{
File.Delete(pendingFile);
}
catch
{
}
}
}
private static void CopyWithRetry(string sourcePath, string destinationPath, bool overwrite)
{
Retry(() => File.Copy(sourcePath, destinationPath, overwrite));
}
private static void MoveWithOverwriteRetry(string sourcePath, string destinationPath)
{
Retry(() => File.Move(sourcePath, destinationPath, overwrite: true));
}
private static void DeleteFileWithRetry(string filePath)
{
Retry(() =>
{
if (File.Exists(filePath))
{
File.Delete(filePath);
}
});
}
private static void Retry(Action action)
{
Exception? lastException = null;
for (var attempt = 0; attempt <= RetryDelays.Length; attempt++)
{
try
{
action();
return;
}
catch (Exception ex) when (ex is IOException or UnauthorizedAccessException)
{
lastException = ex;
if (attempt >= RetryDelays.Length)
{
break;
}
Thread.Sleep(RetryDelays[attempt]);
}
}
if (lastException is not null)
{
throw lastException;
}
}
private static string BuildInstalledPackageFileName(string pluginId)
{
var invalidChars = Path.GetInvalidFileNameChars();
var fileName = new string(pluginId.Select(ch => invalidChars.Contains(ch) ? '_' : ch).ToArray());
return fileName + PackageFileExtension;
}
private static string EnsureTrailingSeparator(string path)
{
return path.EndsWith(Path.DirectorySeparatorChar.ToString(), StringComparison.Ordinal)
? path
: path + Path.DirectorySeparatorChar;
}
}
/// <summary>
/// 简化的插件清单模型
/// </summary>
public class PluginManifest
{
public string Id { get; set; } = "";
public string Name { get; set; } = "";
public string Version { get; set; } = "";
public string? Description { get; set; }
public string? Author { get; set; }
}

View File

@@ -0,0 +1,94 @@
using System.Text.Json;
using LanMountainDesktop.Launcher.Models;
namespace LanMountainDesktop.Launcher.Services;
internal sealed class PluginUpgradeQueueService
{
private const string PendingUpgradesFileName = ".pending-plugin-upgrades.json";
private readonly PluginInstallerService _installerService;
public PluginUpgradeQueueService(PluginInstallerService installerService)
{
_installerService = installerService;
}
public LauncherResult ApplyPendingUpgrades(string pluginsDirectory)
{
var pendingPath = Path.Combine(pluginsDirectory, PendingUpgradesFileName);
if (!File.Exists(pendingPath))
{
return new LauncherResult
{
Success = true,
Stage = "plugin.update",
Code = "noop",
Message = "No pending plugin upgrades."
};
}
var text = File.ReadAllText(pendingPath);
var pending = JsonSerializer.Deserialize(text, AppJsonContext.Default.ListPendingUpgrade) ?? [];
var failures = new List<string>();
var succeeded = new List<PendingUpgrade>();
foreach (var item in pending)
{
if (!item.IsValid())
{
failures.Add(item.PluginId);
continue;
}
try
{
_installerService.InstallPackage(item.SourcePackagePath, pluginsDirectory);
succeeded.Add(item);
}
catch
{
failures.Add(item.PluginId);
}
}
var remaining = pending
.Except(succeeded)
.Where(item => failures.Contains(item.PluginId, StringComparer.OrdinalIgnoreCase))
.ToList();
if (remaining.Count == 0)
{
File.Delete(pendingPath);
}
else
{
File.WriteAllText(pendingPath, JsonSerializer.Serialize(remaining, AppJsonContext.Default.ListPendingUpgrade));
}
return new LauncherResult
{
Success = failures.Count == 0,
Stage = "plugin.update",
Code = failures.Count == 0 ? "ok" : "partial_failed",
Message = failures.Count == 0
? $"Applied {succeeded.Count} pending plugin upgrade(s)."
: $"Applied {succeeded.Count} upgrades, failed: {string.Join(", ", failures)}."
};
}
}
internal sealed record PendingUpgrade(
string PluginId,
string SourcePackagePath,
string TargetVersion,
DateTimeOffset CreatedAt)
{
public bool IsValid()
{
return !string.IsNullOrWhiteSpace(PluginId) &&
!string.IsNullOrWhiteSpace(SourcePackagePath) &&
!string.IsNullOrWhiteSpace(TargetVersion) &&
File.Exists(SourcePackagePath);
}
}

View File

@@ -0,0 +1,161 @@
using System.Net.Http;
using System.Net.Http.Json;
using System.Text.Json;
using System.Text.Json.Serialization;
using LanMountainDesktop.Launcher.Models;
namespace LanMountainDesktop.Launcher.Services;
/// <summary>
/// 更新检查服务 - 基于 GitHub Release API
/// </summary>
internal sealed class UpdateCheckService
{
private const string GitHubApiBase = "https://api.github.com";
private readonly string _repoOwner;
private readonly string _repoName;
private readonly HttpClient _httpClient;
public UpdateCheckService(string repoOwner, string repoName)
{
_repoOwner = repoOwner;
_repoName = repoName;
_httpClient = new HttpClient();
_httpClient.DefaultRequestHeaders.Add("User-Agent", "LanMountainDesktop-Launcher");
_httpClient.DefaultRequestHeaders.Add("Accept", "application/vnd.github+json");
}
/// <summary>
/// 检查更新
/// </summary>
public async Task<UpdateCheckResult> CheckForUpdateAsync(
string currentVersion,
UpdateChannel channel,
CancellationToken cancellationToken = default)
{
try
{
var releases = await FetchReleasesAsync(cancellationToken);
// 根据频道过滤版本
var filteredReleases = channel == UpdateChannel.Stable
? releases.Where(r => !r.Prerelease).ToList()
: releases;
// 找到最新版本
var latestRelease = filteredReleases
.OrderByDescending(r => ParseVersion(r.TagName))
.FirstOrDefault();
if (latestRelease == null)
{
return new UpdateCheckResult
{
HasUpdate = false,
CurrentVersion = currentVersion,
ErrorMessage = "No releases found"
};
}
var latestVersion = ParseVersionString(latestRelease.TagName);
var current = ParseVersion(currentVersion);
var latest = ParseVersion(latestVersion);
return new UpdateCheckResult
{
HasUpdate = latest > current,
LatestVersion = latestVersion,
CurrentVersion = currentVersion,
Release = latestRelease
};
}
catch (Exception ex)
{
return new UpdateCheckResult
{
HasUpdate = false,
CurrentVersion = currentVersion,
ErrorMessage = ex.Message
};
}
}
/// <summary>
/// 获取所有 Release
/// </summary>
private async Task<List<ReleaseInfo>> FetchReleasesAsync(CancellationToken cancellationToken)
{
var url = $"{GitHubApiBase}/repos/{_repoOwner}/{_repoName}/releases";
var response = await _httpClient.GetAsync(url, cancellationToken);
response.EnsureSuccessStatusCode();
var json = await response.Content.ReadAsStringAsync(cancellationToken);
var releases = JsonSerializer.Deserialize(json, AppJsonContext.Default.ListGitHubRelease);
return releases?.Select(r => new ReleaseInfo
{
TagName = r.TagName ?? "",
Name = r.Name ?? "",
Prerelease = r.Prerelease,
PublishedAt = r.PublishedAt,
Body = r.Body,
Assets = r.Assets?.Select(a => new ReleaseAsset
{
Name = a.Name ?? "",
BrowserDownloadUrl = a.BrowserDownloadUrl ?? "",
Size = a.Size
}).ToList() ?? []
}).ToList() ?? [];
}
/// <summary>
/// 从 tag 解析版本号 (例如: v1.0.0 -> 1.0.0)
/// </summary>
private static string ParseVersionString(string tag)
{
return tag.TrimStart('v', 'V');
}
/// <summary>
/// 解析版本号
/// </summary>
private static Version ParseVersion(string versionString)
{
var cleaned = ParseVersionString(versionString);
return Version.TryParse(cleaned, out var version) ? version : new Version(0, 0, 0);
}
}
// GitHub API 响应模型
internal sealed class GitHubRelease
{
[JsonPropertyName("tag_name")]
public string? TagName { get; set; }
[JsonPropertyName("name")]
public string? Name { get; set; }
[JsonPropertyName("prerelease")]
public bool Prerelease { get; set; }
[JsonPropertyName("published_at")]
public DateTime PublishedAt { get; set; }
[JsonPropertyName("body")]
public string? Body { get; set; }
[JsonPropertyName("assets")]
public List<GitHubAsset>? Assets { get; set; }
}
internal sealed class GitHubAsset
{
[JsonPropertyName("name")]
public string? Name { get; set; }
[JsonPropertyName("browser_download_url")]
public string? BrowserDownloadUrl { get; set; }
[JsonPropertyName("size")]
public long Size { get; set; }
}

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,263 @@
using System.ComponentModel;
using System.Runtime.CompilerServices;
using System.Windows.Input;
namespace LanMountainDesktop.Launcher.ViewModels;
/// <summary>
/// 开发调试窗口 ViewModel
/// </summary>
public sealed class DevDebugWindowViewModel : INotifyPropertyChanged
{
private bool _isSplashEnabled = true;
private bool _isErrorEnabled = true;
private bool _isUpdateEnabled = true;
private bool _isOobeEnabled = true;
private string _statusMessage = "就绪";
public event PropertyChangedEventHandler? PropertyChanged;
#region
/// <summary>
/// 启动画面是否启用实际功能
/// </summary>
public bool IsSplashEnabled
{
get => _isSplashEnabled;
set
{
if (_isSplashEnabled != value)
{
_isSplashEnabled = value;
OnPropertyChanged();
UpdateStatus($"启动画面: {(value ? "" : "")}");
}
}
}
/// <summary>
/// 错误页面是否启用实际功能
/// </summary>
public bool IsErrorEnabled
{
get => _isErrorEnabled;
set
{
if (_isErrorEnabled != value)
{
_isErrorEnabled = value;
OnPropertyChanged();
UpdateStatus($"错误页面: {(value ? "" : "")}");
}
}
}
/// <summary>
/// 更新页面是否启用实际功能
/// </summary>
public bool IsUpdateEnabled
{
get => _isUpdateEnabled;
set
{
if (_isUpdateEnabled != value)
{
_isUpdateEnabled = value;
OnPropertyChanged();
UpdateStatus($"更新页面: {(value ? "" : "")}");
}
}
}
/// <summary>
/// OOBE页面是否启用实际功能
/// </summary>
public bool IsOobeEnabled
{
get => _isOobeEnabled;
set
{
if (_isOobeEnabled != value)
{
_isOobeEnabled = value;
OnPropertyChanged();
UpdateStatus($"OOBE页面: {(value ? "" : "")}");
}
}
}
#endregion
#region
/// <summary>
/// 状态消息
/// </summary>
public string StatusMessage
{
get => _statusMessage;
private set
{
if (_statusMessage != value)
{
_statusMessage = value;
OnPropertyChanged();
}
}
}
#endregion
#region
/// <summary>
/// 打开启动画面命令
/// </summary>
public ICommand OpenSplashCommand { get; }
/// <summary>
/// 打开错误页面命令
/// </summary>
public ICommand OpenErrorCommand { get; }
/// <summary>
/// 打开更新页面命令
/// </summary>
public ICommand OpenUpdateCommand { get; }
/// <summary>
/// 打开OOBE页面命令
/// </summary>
public ICommand OpenOobeCommand { get; }
/// <summary>
/// 全部切换到查看模式命令
/// </summary>
public ICommand SetAllViewOnlyCommand { get; }
/// <summary>
/// 全部切换到功能模式命令
/// </summary>
public ICommand SetAllFunctionalCommand { get; }
/// <summary>
/// 关闭窗口命令
/// </summary>
public ICommand CloseCommand { get; }
#endregion
#region
/// <summary>
/// 请求打开启动画面
/// </summary>
public event EventHandler<SplashOpenEventArgs>? OpenSplashRequested;
/// <summary>
/// 请求打开错误页面
/// </summary>
public event EventHandler<ErrorOpenEventArgs>? OpenErrorRequested;
/// <summary>
/// 请求打开更新页面
/// </summary>
public event EventHandler<UpdateOpenEventArgs>? OpenUpdateRequested;
/// <summary>
/// 请求打开OOBE页面
/// </summary>
public event EventHandler<OobeOpenEventArgs>? OpenOobeRequested;
/// <summary>
/// 请求关闭窗口
/// </summary>
public event EventHandler? CloseRequested;
#endregion
public DevDebugWindowViewModel()
{
OpenSplashCommand = new RelayCommand(() =>
{
OpenSplashRequested?.Invoke(this, new SplashOpenEventArgs(IsSplashEnabled));
});
OpenErrorCommand = new RelayCommand(() =>
{
OpenErrorRequested?.Invoke(this, new ErrorOpenEventArgs(IsErrorEnabled));
});
OpenUpdateCommand = new RelayCommand(() =>
{
OpenUpdateRequested?.Invoke(this, new UpdateOpenEventArgs(IsUpdateEnabled));
});
OpenOobeCommand = new RelayCommand(() =>
{
OpenOobeRequested?.Invoke(this, new OobeOpenEventArgs(IsOobeEnabled));
});
SetAllViewOnlyCommand = new RelayCommand(() =>
{
IsSplashEnabled = false;
IsErrorEnabled = false;
IsUpdateEnabled = false;
IsOobeEnabled = false;
UpdateStatus("全部页面已切换到查看模式");
});
SetAllFunctionalCommand = new RelayCommand(() =>
{
IsSplashEnabled = true;
IsErrorEnabled = true;
IsUpdateEnabled = true;
IsOobeEnabled = true;
UpdateStatus("全部页面已切换到功能模式");
});
CloseCommand = new RelayCommand(() =>
{
CloseRequested?.Invoke(this, EventArgs.Empty);
});
}
private void UpdateStatus(string message)
{
StatusMessage = $"[{DateTime.Now:HH:mm:ss}] {message}";
}
private void OnPropertyChanged([CallerMemberName] string? propertyName = null)
{
PropertyChanged?.Invoke(this, new PropertyChangedEventArgs(propertyName));
}
}
#region
public class SplashOpenEventArgs : EventArgs
{
public bool IsFunctional { get; }
public SplashOpenEventArgs(bool isFunctional) => IsFunctional = isFunctional;
}
public class ErrorOpenEventArgs : EventArgs
{
public bool IsFunctional { get; }
public ErrorOpenEventArgs(bool isFunctional) => IsFunctional = isFunctional;
}
public class UpdateOpenEventArgs : EventArgs
{
public bool IsFunctional { get; }
public UpdateOpenEventArgs(bool isFunctional) => IsFunctional = isFunctional;
}
public class OobeOpenEventArgs : EventArgs
{
public bool IsFunctional { get; }
public OobeOpenEventArgs(bool isFunctional) => IsFunctional = isFunctional;
}
#endregion

View File

@@ -0,0 +1,67 @@
using System.Windows.Input;
namespace LanMountainDesktop.Launcher.ViewModels;
/// <summary>
/// 简单的命令实现
/// </summary>
public class RelayCommand : ICommand
{
private readonly Action _execute;
private readonly Func<bool>? _canExecute;
public RelayCommand(Action execute, Func<bool>? canExecute = null)
{
_execute = execute ?? throw new ArgumentNullException(nameof(execute));
_canExecute = canExecute;
}
public bool CanExecute(object? parameter)
{
return _canExecute?.Invoke() ?? true;
}
public void Execute(object? parameter)
{
_execute();
}
public event EventHandler? CanExecuteChanged;
public void RaiseCanExecuteChanged()
{
CanExecuteChanged?.Invoke(this, EventArgs.Empty);
}
}
/// <summary>
/// 带参数的 RelayCommand
/// </summary>
public class RelayCommand<T> : ICommand
{
private readonly Action<T> _execute;
private readonly Predicate<T>? _canExecute;
public RelayCommand(Action<T> execute, Predicate<T>? canExecute = null)
{
_execute = execute ?? throw new ArgumentNullException(nameof(execute));
_canExecute = canExecute;
}
public bool CanExecute(object? parameter)
{
return _canExecute?.Invoke((T)parameter!) ?? true;
}
public void Execute(object? parameter)
{
_execute((T)parameter!);
}
public event EventHandler? CanExecuteChanged;
public void RaiseCanExecuteChanged()
{
CanExecuteChanged?.Invoke(this, EventArgs.Empty);
}
}

View File

@@ -0,0 +1,182 @@
<Window xmlns="https://github.com/avaloniaui"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
xmlns:d="http://schemas.microsoft.com/expression/blend/2008"
xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006"
xmlns:vm="clr-namespace:LanMountainDesktop.Launcher.ViewModels"
mc:Ignorable="d" d:DesignWidth="500" d:DesignHeight="600"
x:Class="LanMountainDesktop.Launcher.Views.DevDebugWindow"
x:DataType="vm:DevDebugWindowViewModel"
Title="开发调试窗口 - Launcher"
Width="500"
Height="600"
WindowStartupLocation="CenterScreen"
Icon="/Assets/logo.ico">
<Design.DataContext>
<vm:DevDebugWindowViewModel />
</Design.DataContext>
<Border Padding="20"
Background="{DynamicResource SystemControlBackgroundAltHighBrush}">
<Grid RowDefinitions="Auto,*,Auto,Auto">
<!-- 标题 -->
<StackPanel Grid.Row="0" Margin="0,0,0,20">
<TextBlock Text="🛠️ 开发调试窗口"
FontSize="24"
FontWeight="Bold"
Foreground="{DynamicResource SystemControlForegroundBaseHighBrush}" />
<TextBlock Text="用于开发和调试 Launcher 的各个页面"
FontSize="12"
Opacity="0.7"
Margin="0,5,0,0"
Foreground="{DynamicResource SystemControlForegroundBaseMediumBrush}" />
</StackPanel>
<!-- 页面列表 -->
<ScrollViewer Grid.Row="1" VerticalScrollBarVisibility="Auto">
<StackPanel Spacing="15">
<!-- 启动画面 -->
<Border Background="{DynamicResource SystemControlBackgroundAltMediumBrush}"
CornerRadius="8"
Padding="15">
<Grid ColumnDefinitions="*,Auto">
<StackPanel Grid.Column="0">
<TextBlock Text="🚀 启动画面 (SplashWindow)"
FontWeight="SemiBold"
FontSize="14" />
<TextBlock Text="显示启动进度和状态"
FontSize="11"
Opacity="0.6"
Margin="0,3,0,0" />
</StackPanel>
<StackPanel Grid.Column="1" Spacing="8">
<ToggleSwitch Content="启用功能"
IsChecked="{Binding IsSplashEnabled}"
OnContent="功能"
OffContent="查看" />
<Button Content="打开"
Command="{Binding OpenSplashCommand}"
HorizontalAlignment="Right" />
</StackPanel>
</Grid>
</Border>
<!-- 错误页面 -->
<Border Background="{DynamicResource SystemControlBackgroundAltMediumBrush}"
CornerRadius="8"
Padding="15">
<Grid ColumnDefinitions="*,Auto">
<StackPanel Grid.Column="0">
<TextBlock Text="❌ 错误页面 (ErrorWindow)"
FontWeight="SemiBold"
FontSize="14" />
<TextBlock Text="显示错误信息和重试选项"
FontSize="11"
Opacity="0.6"
Margin="0,3,0,0" />
</StackPanel>
<StackPanel Grid.Column="1" Spacing="8">
<ToggleSwitch Content="启用功能"
IsChecked="{Binding IsErrorEnabled}"
OnContent="功能"
OffContent="查看" />
<Button Content="打开"
Command="{Binding OpenErrorCommand}"
HorizontalAlignment="Right" />
</StackPanel>
</Grid>
</Border>
<!-- 更新页面 -->
<Border Background="{DynamicResource SystemControlBackgroundAltMediumBrush}"
CornerRadius="8"
Padding="15">
<Grid ColumnDefinitions="*,Auto">
<StackPanel Grid.Column="0">
<TextBlock Text="⬆️ 更新页面 (UpdateWindow)"
FontWeight="SemiBold"
FontSize="14" />
<TextBlock Text="显示更新进度和状态"
FontSize="11"
Opacity="0.6"
Margin="0,3,0,0" />
</StackPanel>
<StackPanel Grid.Column="1" Spacing="8">
<ToggleSwitch Content="启用功能"
IsChecked="{Binding IsUpdateEnabled}"
OnContent="功能"
OffContent="查看" />
<Button Content="打开"
Command="{Binding OpenUpdateCommand}"
HorizontalAlignment="Right" />
</StackPanel>
</Grid>
</Border>
<!-- OOBE页面 -->
<Border Background="{DynamicResource SystemControlBackgroundAltMediumBrush}"
CornerRadius="8"
Padding="15">
<Grid ColumnDefinitions="*,Auto">
<StackPanel Grid.Column="0">
<TextBlock Text="👋 OOBE页面 (OobeWindow)"
FontWeight="SemiBold"
FontSize="14" />
<TextBlock Text="首次运行引导页面"
FontSize="11"
Opacity="0.6"
Margin="0,3,0,0" />
</StackPanel>
<StackPanel Grid.Column="1" Spacing="8">
<ToggleSwitch Content="启用功能"
IsChecked="{Binding IsOobeEnabled}"
OnContent="功能"
OffContent="查看" />
<Button Content="打开"
Command="{Binding OpenOobeCommand}"
HorizontalAlignment="Right" />
</StackPanel>
</Grid>
</Border>
</StackPanel>
</ScrollViewer>
<!-- 批量操作 -->
<StackPanel Grid.Row="2"
Orientation="Horizontal"
HorizontalAlignment="Center"
Spacing="10"
Margin="0,15">
<Button Content="全部设为查看模式"
Command="{Binding SetAllViewOnlyCommand}"
Background="{DynamicResource SystemControlBackgroundAltMediumBrush}" />
<Button Content="全部设为功能模式"
Command="{Binding SetAllFunctionalCommand}"
Background="{DynamicResource SystemControlHighlightAccentBrush}"
Foreground="White" />
</StackPanel>
<!-- 底部状态栏 -->
<Border Grid.Row="3"
Background="{DynamicResource SystemControlBackgroundAltMediumBrush}"
CornerRadius="4"
Padding="10">
<Grid ColumnDefinitions="*,Auto">
<TextBlock Grid.Column="0"
Text="{Binding StatusMessage}"
FontSize="11"
Opacity="0.8"
TextTrimming="CharacterEllipsis" />
<Button Grid.Column="1"
Content="关闭"
Command="{Binding CloseCommand}"
Padding="15,5" />
</Grid>
</Border>
</Grid>
</Border>
</Window>

View File

@@ -0,0 +1,196 @@
using Avalonia.Controls;
using Avalonia.Markup.Xaml;
using LanMountainDesktop.Launcher.Services;
using LanMountainDesktop.Launcher.ViewModels;
using LanMountainDesktop.Launcher.Views;
namespace LanMountainDesktop.Launcher.Views;
/// <summary>
/// 开发调试窗口
/// </summary>
public partial class DevDebugWindow : Window
{
private readonly DevDebugWindowViewModel _viewModel;
public DevDebugWindow()
{
AvaloniaXamlLoader.Load(this);
_viewModel = new DevDebugWindowViewModel();
DataContext = _viewModel;
// 订阅事件
_viewModel.OpenSplashRequested += OnOpenSplashRequested;
_viewModel.OpenErrorRequested += OnOpenErrorRequested;
_viewModel.OpenUpdateRequested += OnOpenUpdateRequested;
_viewModel.OpenOobeRequested += OnOpenOobeRequested;
_viewModel.CloseRequested += OnCloseRequested;
}
/// <summary>
/// 打开启动画面
/// </summary>
private void OnOpenSplashRequested(object? sender, SplashOpenEventArgs e)
{
var splashWindow = new SplashWindow();
if (!e.IsFunctional)
{
// 查看模式:显示模拟内容
splashWindow.SetDebugMode(true);
}
splashWindow.Show();
if (e.IsFunctional)
{
// 功能模式:模拟正常启动流程
_ = SimulateSplashProgress(splashWindow);
}
}
/// <summary>
/// 打开错误页面
/// </summary>
private void OnOpenErrorRequested(object? sender, ErrorOpenEventArgs e)
{
var errorWindow = new ErrorWindow();
if (!e.IsFunctional)
{
// 查看模式:显示模拟错误
errorWindow.SetDebugMode(true);
errorWindow.SetErrorMessage("[调试模式] 这是一个模拟的错误消息,用于查看错误页面的样式和布局。");
}
else
{
// 功能模式:显示真实错误
errorWindow.SetErrorMessage("找不到阑山桌面应用程序。\n\n请检查应用安装是否完整。");
}
errorWindow.Show();
}
/// <summary>
/// 打开更新页面
/// </summary>
private void OnOpenUpdateRequested(object? sender, UpdateOpenEventArgs e)
{
var updateWindow = new UpdateWindow();
if (!e.IsFunctional)
{
// 查看模式:显示模拟更新
updateWindow.SetDebugMode(true);
}
updateWindow.Show();
if (e.IsFunctional)
{
// 功能模式:模拟更新进度
_ = SimulateUpdateProgress(updateWindow);
}
}
/// <summary>
/// 打开OOBE页面
/// </summary>
private void OnOpenOobeRequested(object? sender, OobeOpenEventArgs e)
{
var oobeWindow = new OobeWindow();
if (!e.IsFunctional)
{
// 查看模式:显示调试标记(通过标题)
oobeWindow.Title = "[调试模式] 欢迎使用阑山桌面";
}
oobeWindow.Show();
if (e.IsFunctional)
{
// 功能模式:等待用户点击后自动关闭
_ = SimulateOobeProgress(oobeWindow);
}
}
/// <summary>
/// 模拟OOBE流程
/// </summary>
private async Task SimulateOobeProgress(OobeWindow oobeWindow)
{
try
{
// 等待用户点击开始按钮
await oobeWindow.WaitForEnterAsync();
// 用户点击后窗口会自动关闭通过OobeWindow内部的动画和关闭逻辑
Console.WriteLine("[DevDebugWindow] OOBE completed by user");
}
catch (Exception ex)
{
Console.Error.WriteLine($"[DevDebugWindow] Error during OOBE simulation: {ex.Message}");
}
}
/// <summary>
/// 关闭窗口
/// </summary>
private void OnCloseRequested(object? sender, EventArgs e)
{
Close();
}
/// <summary>
/// 模拟启动画面进度
/// </summary>
private async Task SimulateSplashProgress(SplashWindow splashWindow)
{
var stages = new[] { "初始化", "检查更新", "加载组件", "启动应用" };
var reporter = (ISplashStageReporter)splashWindow;
for (int i = 0; i < stages.Length; i++)
{
reporter.ReportStage(stages[i], (i + 1) * 25);
await Task.Delay(500);
}
// 3秒后关闭
await Task.Delay(3000);
splashWindow.Close();
}
/// <summary>
/// 模拟更新进度
/// </summary>
private async Task SimulateUpdateProgress(UpdateWindow updateWindow)
{
var stages = new[] { "下载", "验证", "安装", "清理" };
foreach (var stage in stages)
{
updateWindow.Report(stage, $"正在{stage}...", Array.IndexOf(stages, stage) * 25 + 10);
await Task.Delay(800);
}
updateWindow.ReportComplete(true, null);
// 2秒后关闭
await Task.Delay(2000);
updateWindow.Close();
}
protected override void OnClosed(EventArgs e)
{
// 取消订阅事件
_viewModel.OpenSplashRequested -= OnOpenSplashRequested;
_viewModel.OpenErrorRequested -= OnOpenErrorRequested;
_viewModel.OpenUpdateRequested -= OnOpenUpdateRequested;
_viewModel.OpenOobeRequested -= OnOpenOobeRequested;
_viewModel.CloseRequested -= OnCloseRequested;
base.OnClosed(e);
}
}

View File

@@ -0,0 +1,107 @@
<Window xmlns="https://github.com/avaloniaui"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
xmlns:d="http://schemas.microsoft.com/expression/blend/2008"
xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006"
xmlns:views="clr-namespace:LanMountainDesktop.Launcher.Views"
mc:Ignorable="d"
d:DesignWidth="420"
d:DesignHeight="320"
x:Class="LanMountainDesktop.Launcher.Views.ErrorDebugWindow"
x:DataType="views:ErrorDebugWindow"
Title="调试模式"
Width="420"
Height="320"
CanResize="False"
WindowStartupLocation="CenterOwner"
Background="{DynamicResource SolidBackgroundFillColorBaseBrush}"
TransparencyLevelHint="None"
Icon="/Assets/logo.ico">
<Design.DataContext>
<views:ErrorDebugWindow />
</Design.DataContext>
<Grid Margin="24" RowDefinitions="Auto,*,Auto">
<!-- 标题 -->
<TextBlock Grid.Row="0"
Text="调试设置"
FontSize="20"
FontWeight="SemiBold"
Foreground="{DynamicResource TextFillColorPrimaryBrush}"
Margin="0,0,0,16" />
<!-- 设置内容 -->
<StackPanel Grid.Row="1" Spacing="16">
<!-- 开发模式开关 -->
<Border Background="{DynamicResource CardBackgroundFillColorDefaultBrush}"
CornerRadius="{DynamicResource ControlCornerRadius}"
Padding="16,12">
<Grid ColumnDefinitions="*,Auto">
<StackPanel Grid.Column="0" VerticalAlignment="Center">
<TextBlock Text="开发模式"
FontSize="14"
Foreground="{DynamicResource TextFillColorPrimaryBrush}" />
<TextBlock Text="启用后自动扫描开发目录"
FontSize="12"
Foreground="{DynamicResource TextFillColorSecondaryBrush}"
Margin="0,2,0,0" />
</StackPanel>
<ToggleSwitch x:Name="DevModeToggle"
Grid.Column="1"
OnContent="开"
OffContent="关" />
</Grid>
</Border>
<!-- 应用路径选择 -->
<Border Background="{DynamicResource CardBackgroundFillColorDefaultBrush}"
CornerRadius="{DynamicResource ControlCornerRadius}"
Padding="16,12">
<Grid RowDefinitions="Auto,Auto" ColumnDefinitions="*,Auto">
<TextBlock Grid.Row="0" Grid.Column="0"
Text="应用路径"
FontSize="14"
Foreground="{DynamicResource TextFillColorPrimaryBrush}" />
<TextBlock x:Name="PathTextBlock"
Grid.Row="1" Grid.Column="0"
Text="未选择"
FontSize="12"
Foreground="{DynamicResource TextFillColorSecondaryBrush}"
TextTrimming="CharacterEllipsis"
Margin="0,4,12,0" />
<Button x:Name="BrowseButton"
Grid.Row="0" Grid.RowSpan="2" Grid.Column="1"
Content="浏览..."
VerticalAlignment="Center" />
</Grid>
</Border>
<!-- 提示信息 -->
<Border Background="{DynamicResource SystemFillColorCautionBackgroundBrush}"
CornerRadius="{DynamicResource ControlCornerRadius}"
Padding="12,10"
IsVisible="True">
<TextBlock Text="此功能仅供开发人员使用"
FontSize="12"
Foreground="{DynamicResource SystemFillColorCautionBrush}"
TextWrapping="Wrap" />
</Border>
</StackPanel>
<!-- 按钮区域 -->
<StackPanel Grid.Row="2"
Orientation="Horizontal"
HorizontalAlignment="Right"
Spacing="12"
Margin="0,16,0,0">
<Button x:Name="CancelButton"
Content="取消"
Width="80"
Height="32" />
<Button x:Name="OkButton"
Content="确定"
Width="80"
Height="32"
Theme="{DynamicResource AccentButtonTheme}" />
</StackPanel>
</Grid>
</Window>

View File

@@ -0,0 +1,172 @@
using Avalonia.Controls;
using Avalonia.Interactivity;
using Avalonia.Markup.Xaml;
using Avalonia.Platform.Storage;
namespace LanMountainDesktop.Launcher.Views;
/// <summary>
/// 错误调试窗口 - 开发人员专用调试设置
/// </summary>
public partial class ErrorDebugWindow : Window
{
private string? _selectedHostPath;
private bool _isInitialized = false;
/// <summary>
/// 是否启用了开发模式
/// </summary>
public bool IsDevModeEnabled { get; private set; }
/// <summary>
/// 选择的主程序路径
/// </summary>
public string? SelectedHostPath => _selectedHostPath;
public ErrorDebugWindow()
{
AvaloniaXamlLoader.Load(this);
// 延迟到窗口加载完成后再初始化组件
this.Loaded += OnWindowLoaded;
}
public ErrorDebugWindow(bool devModeEnabled, string? initialPath) : this()
{
IsDevModeEnabled = devModeEnabled;
_selectedHostPath = initialPath;
}
/// <summary>
/// 窗口加载完成事件
/// </summary>
private void OnWindowLoaded(object? sender, RoutedEventArgs e)
{
if (_isInitialized) return;
_isInitialized = true;
Console.WriteLine("[ErrorDebugWindow] Window loaded, initializing components...");
InitializeComponents();
// 设置初始值(在视觉树准备好后)
var devModeToggle = this.FindControl<ToggleSwitch>("DevModeToggle");
if (devModeToggle is not null)
{
devModeToggle.IsChecked = IsDevModeEnabled;
}
UpdatePathDisplay(_selectedHostPath);
}
private void InitializeComponents()
{
// 开发模式开关
var devModeToggle = this.FindControl<ToggleSwitch>("DevModeToggle");
if (devModeToggle is not null)
{
devModeToggle.IsCheckedChanged += (s, e) =>
{
IsDevModeEnabled = devModeToggle.IsChecked ?? false;
Console.WriteLine($"[ErrorDebugWindow] DevMode changed to: {IsDevModeEnabled}");
};
Console.WriteLine("[ErrorDebugWindow] DevModeToggle event bound");
}
else
{
Console.Error.WriteLine("[ErrorDebugWindow] Failed to find DevModeToggle!");
}
// 浏览按钮
var browseButton = this.FindControl<Button>("BrowseButton");
if (browseButton is not null)
{
browseButton.Click += OnBrowseClick;
Console.WriteLine("[ErrorDebugWindow] BrowseButton event bound");
}
else
{
Console.Error.WriteLine("[ErrorDebugWindow] Failed to find BrowseButton!");
}
// 确定按钮
var okButton = this.FindControl<Button>("OkButton");
if (okButton is not null)
{
okButton.Click += (s, e) => Close();
Console.WriteLine("[ErrorDebugWindow] OkButton event bound");
}
else
{
Console.Error.WriteLine("[ErrorDebugWindow] Failed to find OkButton!");
}
// 取消按钮
var cancelButton = this.FindControl<Button>("CancelButton");
if (cancelButton is not null)
{
cancelButton.Click += (s, e) =>
{
// 取消时恢复原始状态
IsDevModeEnabled = false;
_selectedHostPath = null;
Console.WriteLine("[ErrorDebugWindow] Cancel clicked, resetting state");
Close();
};
Console.WriteLine("[ErrorDebugWindow] CancelButton event bound");
}
else
{
Console.Error.WriteLine("[ErrorDebugWindow] Failed to find CancelButton!");
}
Console.WriteLine("[ErrorDebugWindow] Components initialization completed");
}
/// <summary>
/// 浏览按钮点击
/// </summary>
private async void OnBrowseClick(object? sender, RoutedEventArgs e)
{
var storageProvider = StorageProvider;
if (storageProvider is null) return;
var options = new FilePickerOpenOptions
{
Title = "选择阑山桌面主程序",
AllowMultiple = false,
FileTypeFilter = new[]
{
new FilePickerFileType("可执行文件")
{
Patterns = OperatingSystem.IsWindows()
? new[] { "*.exe" }
: new[] { "*" }
}
}
};
var result = await storageProvider.OpenFilePickerAsync(options);
if (result.Count > 0)
{
_selectedHostPath = result[0].Path.LocalPath;
Console.WriteLine($"[ErrorDebugWindow] Selected host path: {_selectedHostPath}");
UpdatePathDisplay(_selectedHostPath);
}
}
/// <summary>
/// 更新路径显示
/// </summary>
private void UpdatePathDisplay(string? path)
{
var pathTextBlock = this.FindControl<TextBlock>("PathTextBlock");
if (pathTextBlock is not null)
{
pathTextBlock.Text = string.IsNullOrEmpty(path) ? "未选择" : path;
}
else
{
Console.Error.WriteLine("[ErrorDebugWindow] Failed to find PathTextBlock!");
}
}
}

View File

@@ -0,0 +1,105 @@
<Window xmlns="https://github.com/avaloniaui"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
xmlns:d="http://schemas.microsoft.com/expression/blend/2008"
xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006"
xmlns:views="clr-namespace:LanMountainDesktop.Launcher.Views"
xmlns:ui="using:FluentAvalonia.UI.Controls"
mc:Ignorable="d"
d:DesignWidth="520"
d:DesignHeight="280"
x:Class="LanMountainDesktop.Launcher.Views.ErrorWindow"
x:DataType="views:ErrorWindow"
Title="阑山桌面"
Width="520"
Height="280"
CanResize="False"
WindowStartupLocation="CenterScreen"
Background="{DynamicResource SolidBackgroundFillColorBaseBrush}"
TransparencyLevelHint="None"
Icon="/Assets/logo.ico">
<Design.DataContext>
<views:ErrorWindow />
</Design.DataContext>
<!-- Fluent Design 风格对话框布局 -->
<Grid RowDefinitions="*,Auto">
<!-- 主内容区域:左侧图标 + 右侧文字 -->
<Grid Grid.Row="0" Margin="24,24,24,16" ColumnDefinitions="Auto,*">
<!-- 左侧:错误图标(可点击进入调试模式) -->
<Border x:Name="ErrorIconBorder"
Grid.Column="0"
Width="48"
Height="48"
Margin="0,4,16,0"
Background="{DynamicResource SystemFillColorCriticalBackgroundBrush}"
CornerRadius="24"
VerticalAlignment="Top">
<TextBlock Text="&#xEA39;"
FontSize="24"
FontFamily="{DynamicResource SymbolThemeFontFamily}"
Foreground="{DynamicResource SystemFillColorCriticalBrush}"
HorizontalAlignment="Center"
VerticalAlignment="Center"/>
</Border>
<!-- 右侧:标题 + 内容 -->
<StackPanel Grid.Column="1" Spacing="8">
<!-- 标题 -->
<TextBlock x:Name="TitleText"
Text="启动失败"
FontSize="18"
FontWeight="SemiBold"
Foreground="{DynamicResource TextFillColorPrimaryBrush}"
TextWrapping="Wrap"/>
<!-- 错误信息 -->
<TextBlock x:Name="ErrorMessageText"
Text="找不到阑山桌面应用程序。"
FontSize="14"
Foreground="{DynamicResource TextFillColorSecondaryBrush}"
TextWrapping="Wrap"
LineHeight="20"/>
<!-- 建议信息 -->
<TextBlock x:Name="SuggestionText"
Text="请确保应用程序已正确安装,或尝试重新安装。"
FontSize="13"
Foreground="{DynamicResource TextFillColorTertiaryBrush}"
TextWrapping="Wrap"
LineHeight="18"
Margin="0,4,0,0"/>
</StackPanel>
</Grid>
<!-- 底部:按钮区域 -->
<Border Grid.Row="1"
Background="{DynamicResource CardBackgroundFillColorDefaultBrush}"
Padding="24,16">
<Grid ColumnDefinitions="*,Auto">
<Button x:Name="OpenLogButton"
Grid.Column="0"
Content="打开日志"
Width="100"
Height="32"
FontSize="13"
HorizontalAlignment="Left"/>
<StackPanel Grid.Column="1"
Orientation="Horizontal"
Spacing="8">
<Button x:Name="ExitButton"
Content="退出"
Width="80"
Height="32"
FontSize="13"/>
<Button x:Name="RetryButton"
Content="重试"
Width="80"
Height="32"
FontSize="13"
Theme="{DynamicResource AccentButtonTheme}"/>
</StackPanel>
</Grid>
</Border>
</Grid>
</Window>

View File

@@ -0,0 +1,542 @@
using Avalonia.Controls;
using Avalonia.Interactivity;
using Avalonia.Markup.Xaml;
using Avalonia.Platform.Storage;
using LanMountainDesktop.Launcher.Services;
using System.Diagnostics;
namespace LanMountainDesktop.Launcher.Views;
/// <summary>
/// 错误窗口 - 显示启动失败信息,支持调试模式(隐藏入口)
/// </summary>
public partial class ErrorWindow : Window
{
private readonly TaskCompletionSource<ErrorWindowResult> _completionSource = new();
private int _iconClickCount = 0;
private const int DebugModeClickThreshold = 5;
private bool _isDebugMode = false;
private string? _customHostPath;
private bool _devModeEnabled;
public ErrorWindow()
{
AvaloniaXamlLoader.Load(this);
// 先加载保存的状态
_devModeEnabled = LoadDevModeStateInternal();
_customHostPath = LoadCustomHostPathInternal();
// 延迟到窗口加载完成后再初始化组件,确保视觉树已准备好
this.Loaded += OnWindowLoaded;
this.Opened += OnWindowOpened;
}
/// <summary>
/// 窗口加载完成事件 - 视觉树已准备好
/// </summary>
private void OnWindowLoaded(object? sender, RoutedEventArgs e)
{
Console.WriteLine("[ErrorWindow] Window loaded, initializing components...");
InitializeComponents();
}
/// <summary>
/// 窗口打开事件
/// </summary>
private void OnWindowOpened(object? sender, EventArgs e)
{
Console.WriteLine("[ErrorWindow] Window opened and visible");
}
private void InitializeComponents()
{
Console.WriteLine("[ErrorWindow] Initializing components...");
// 错误图标点击事件(进入调试模式 - 隐藏功能)
var errorIconBorder = this.FindControl<Border>("ErrorIconBorder");
if (errorIconBorder is not null)
{
errorIconBorder.PointerPressed += OnErrorIconClick;
Console.WriteLine("[ErrorWindow] ErrorIconBorder event bound successfully");
}
else
{
Console.Error.WriteLine("[ErrorWindow] Failed to find ErrorIconBorder!");
}
// 按钮事件
var retryButton = this.FindControl<Button>("RetryButton");
var exitButton = this.FindControl<Button>("ExitButton");
var openLogButton = this.FindControl<Button>("OpenLogButton");
if (retryButton is not null)
{
retryButton.Click += OnRetryClick;
Console.WriteLine("[ErrorWindow] RetryButton event bound");
}
else
{
Console.Error.WriteLine("[ErrorWindow] Failed to find RetryButton!");
}
if (exitButton is not null)
{
exitButton.Click += OnExitClick;
Console.WriteLine("[ErrorWindow] ExitButton event bound");
}
else
{
Console.Error.WriteLine("[ErrorWindow] Failed to find ExitButton!");
}
if (openLogButton is not null)
{
openLogButton.Click += OnOpenLogClick;
Console.WriteLine("[ErrorWindow] OpenLogButton event bound");
}
else
{
Console.Error.WriteLine("[ErrorWindow] Failed to find OpenLogButton!");
}
Console.WriteLine("[ErrorWindow] Components initialization completed");
}
/// <summary>
/// 设置错误消息
/// </summary>
public void SetErrorMessage(string message)
{
var errorText = this.FindControl<TextBlock>("ErrorMessageText");
if (errorText is not null)
{
errorText.Text = message;
}
}
/// <summary>
/// 设置调试模式
/// </summary>
public void SetDebugMode(bool isDebugMode)
{
_isDebugMode = isDebugMode;
var titleText = this.FindControl<TextBlock>("TitleText");
if (titleText is not null && isDebugMode)
{
titleText.Text = "[调试模式] 错误页面";
}
}
/// <summary>
/// 获取用户选择的主程序路径
/// </summary>
public string? GetCustomHostPath() => _customHostPath;
/// <summary>
/// 是否启用了开发模式
/// </summary>
public bool IsDevModeEnabled() => _devModeEnabled;
/// <summary>
/// 等待用户选择
/// </summary>
public Task<ErrorWindowResult> WaitForChoiceAsync()
{
return _completionSource.Task;
}
/// <summary>
/// 错误图标点击事件 - 连续点击 5 次进入调试模式(隐藏功能)
/// </summary>
private void OnErrorIconClick(object? sender, Avalonia.Input.PointerPressedEventArgs e)
{
_iconClickCount++;
if (_iconClickCount >= DebugModeClickThreshold && !_isDebugMode)
{
EnterDebugMode();
}
}
/// <summary>
/// 进入调试模式 - 显示调试窗口
/// </summary>
private async void EnterDebugMode()
{
_isDebugMode = true;
// 创建并显示调试窗口
var debugWindow = new ErrorDebugWindow(_devModeEnabled, _customHostPath)
{
WindowStartupLocation = WindowStartupLocation.CenterOwner
};
// 订阅调试窗口关闭事件
debugWindow.Closed += (s, e) =>
{
// 更新状态
_devModeEnabled = debugWindow.IsDevModeEnabled;
_customHostPath = debugWindow.SelectedHostPath;
// 保存开发模式状态和自定义路径
SaveDevModeStateInternal(_devModeEnabled);
SaveCustomHostPathInternal(_customHostPath);
// 如果启用了开发模式且没有选择路径,自动扫描
if (_devModeEnabled && string.IsNullOrEmpty(_customHostPath))
{
ScanDevPaths();
// 扫描到路径后也保存
if (!string.IsNullOrEmpty(_customHostPath))
{
SaveCustomHostPathInternal(_customHostPath);
}
}
};
await debugWindow.ShowDialog(this);
}
/// <summary>
/// 扫描开发路径
/// </summary>
private void ScanDevPaths()
{
var executable = OperatingSystem.IsWindows() ? "LanMountainDesktop.exe" : "LanMountainDesktop";
var possiblePaths = new[]
{
Path.Combine(AppContext.BaseDirectory, "..", "..", "LanMountainDesktop", "bin", "Debug", "net10.0", executable),
Path.Combine(AppContext.BaseDirectory, "..", "..", "LanMountainDesktop", "bin", "Release", "net10.0", executable),
Path.Combine(AppContext.BaseDirectory, "..", "LanMountainDesktop", "bin", "Debug", "net10.0", executable),
Path.Combine(AppContext.BaseDirectory, "..", "LanMountainDesktop", "bin", "Release", "net10.0", executable),
Path.Combine(AppContext.BaseDirectory, "..", "dev-test", "app-1.0.0-dev", executable),
};
foreach (var path in possiblePaths.Select(Path.GetFullPath).Distinct())
{
if (File.Exists(path))
{
_customHostPath = path;
break;
}
}
}
/// <summary>
/// 获取配置存储的基础目录
/// </summary>
private static string GetConfigBaseDirectory()
{
try
{
// 优先使用 LocalApplicationData用户状态
var appData = Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData);
if (!string.IsNullOrEmpty(appData))
{
var configDir = Path.Combine(appData, "LanMountainDesktop", ".launcher");
return configDir;
}
}
catch
{
// LocalApplicationData 不可用,回退到 Launcher 所在目录
}
// 回退方案:使用 Launcher 所在目录
try
{
var launcherDir = AppContext.BaseDirectory;
var configDir = Path.Combine(launcherDir, ".launcher");
return configDir;
}
catch
{
// 最后的兜底:使用当前目录
return Path.Combine(Directory.GetCurrentDirectory(), ".launcher");
}
}
/// <summary>
/// 确保配置目录存在
/// </summary>
private static bool EnsureConfigDirectory(string dirPath)
{
try
{
if (!Directory.Exists(dirPath))
{
Directory.CreateDirectory(dirPath);
Console.WriteLine($"[ErrorWindow] Created config directory: {dirPath}");
}
return true;
}
catch (Exception ex)
{
Console.Error.WriteLine($"[ErrorWindow] Failed to create config directory: {ex.Message}");
return false;
}
}
/// <summary>
/// 保存开发模式状态(内部方法)
/// </summary>
private static void SaveDevModeStateInternal(bool enabled)
{
try
{
var configDir = GetConfigBaseDirectory();
if (!EnsureConfigDirectory(configDir))
{
Console.Error.WriteLine("[ErrorWindow] Cannot save dev mode: config directory unavailable");
return;
}
var devModeFile = Path.Combine(configDir, "devmode.config");
File.WriteAllText(devModeFile, enabled ? "1" : "0");
Console.WriteLine($"[ErrorWindow] Dev mode state saved: {enabled}");
}
catch (Exception ex)
{
Console.Error.WriteLine($"[ErrorWindow] Failed to save dev mode state: {ex.Message}");
}
}
/// <summary>
/// 加载开发模式状态(内部方法)
/// </summary>
private static bool LoadDevModeStateInternal()
{
try
{
var configDir = GetConfigBaseDirectory();
var devModeFile = Path.Combine(configDir, "devmode.config");
if (File.Exists(devModeFile))
{
var content = File.ReadAllText(devModeFile).Trim();
var enabled = content == "1";
Console.WriteLine($"[ErrorWindow] Dev mode state loaded: {enabled}");
return enabled;
}
}
catch (Exception ex)
{
Console.Error.WriteLine($"[ErrorWindow] Failed to load dev mode state: {ex.Message}");
}
return false;
}
/// <summary>
/// 保存自定义主程序路径(内部方法)
/// </summary>
private static void SaveCustomHostPathInternal(string? path)
{
try
{
var configDir = GetConfigBaseDirectory();
if (!EnsureConfigDirectory(configDir))
{
Console.Error.WriteLine("[ErrorWindow] Cannot save custom path: config directory unavailable");
return;
}
var hostPathFile = Path.Combine(configDir, "custom-host-path.config");
File.WriteAllText(hostPathFile, path ?? string.Empty);
Console.WriteLine($"[ErrorWindow] Custom host path saved: {path}");
}
catch (Exception ex)
{
Console.Error.WriteLine($"[ErrorWindow] Failed to save custom host path: {ex.Message}");
}
}
/// <summary>
/// 加载自定义主程序路径(内部方法)
/// </summary>
private static string? LoadCustomHostPathInternal()
{
try
{
var configDir = GetConfigBaseDirectory();
var hostPathFile = Path.Combine(configDir, "custom-host-path.config");
if (File.Exists(hostPathFile))
{
var content = File.ReadAllText(hostPathFile).Trim();
// 验证路径是否仍然有效
if (!string.IsNullOrEmpty(content) && File.Exists(content))
{
Console.WriteLine($"[ErrorWindow] Custom host path loaded: {content}");
return content;
}
// 路径已失效,清理配置文件
if (!string.IsNullOrEmpty(content))
{
Console.WriteLine($"[ErrorWindow] Custom host path is no longer valid: {content}");
try
{
File.Delete(hostPathFile);
Console.WriteLine("[ErrorWindow] Cleared invalid custom host path");
}
catch (Exception clearEx)
{
Console.Error.WriteLine($"[ErrorWindow] Failed to clear invalid host path: {clearEx.Message}");
}
}
}
}
catch (Exception ex)
{
Console.Error.WriteLine($"[ErrorWindow] Failed to load custom host path: {ex.Message}");
}
return null;
}
/// <summary>
/// 检查是否启用了开发模式(静态方法,启动时调用)
/// </summary>
public static bool CheckDevModeEnabled()
{
return LoadDevModeStateInternal();
}
/// <summary>
/// 获取保存的自定义主程序路径(静态方法,启动时调用)
/// </summary>
public static string? GetSavedCustomHostPath()
{
return LoadCustomHostPathInternal();
}
private void OnRetryClick(object? sender, RoutedEventArgs e)
{
_completionSource.TrySetResult(ErrorWindowResult.Retry);
}
private void OnExitClick(object? sender, RoutedEventArgs e)
{
_completionSource.TrySetResult(ErrorWindowResult.Exit);
}
/// <summary>
/// 打开日志文件
/// </summary>
private async void OnOpenLogClick(object? sender, RoutedEventArgs e)
{
try
{
var logFilePath = Logger.GetLogFilePath();
if (string.IsNullOrEmpty(logFilePath) || !File.Exists(logFilePath))
{
// 如果没有日志文件,打开日志目录
var logDir = Path.GetDirectoryName(logFilePath);
if (!string.IsNullOrEmpty(logDir) && Directory.Exists(logDir))
{
OpenFolder(logDir);
}
else
{
// 尝试打开配置目录
var configDir = GetConfigBaseDirectory();
if (Directory.Exists(configDir))
{
OpenFolder(configDir);
}
else
{
Console.WriteLine("[ErrorWindow] No log file or directory available");
}
}
return;
}
Console.WriteLine($"[ErrorWindow] Opening log file: {logFilePath}");
OpenFile(logFilePath);
}
catch (Exception ex)
{
Console.Error.WriteLine($"[ErrorWindow] Failed to open log: {ex.Message}");
}
}
/// <summary>
/// 打开文件
/// </summary>
private static void OpenFile(string filePath)
{
try
{
if (OperatingSystem.IsWindows())
{
Process.Start(new ProcessStartInfo
{
FileName = "explorer.exe",
Arguments = $"\"{filePath}\"",
UseShellExecute = true
});
}
else if (OperatingSystem.IsMacOS())
{
Process.Start("open", filePath);
}
else if (OperatingSystem.IsLinux())
{
Process.Start("xdg-open", filePath);
}
}
catch (Exception ex)
{
Console.Error.WriteLine($"[ErrorWindow] Failed to open file: {ex.Message}");
}
}
/// <summary>
/// 打开文件夹
/// </summary>
private static void OpenFolder(string folderPath)
{
try
{
if (OperatingSystem.IsWindows())
{
Process.Start(new ProcessStartInfo
{
FileName = "explorer.exe",
Arguments = $"\"{folderPath}\"",
UseShellExecute = true
});
}
else if (OperatingSystem.IsMacOS())
{
Process.Start("open", folderPath);
}
else if (OperatingSystem.IsLinux())
{
Process.Start("xdg-open", folderPath);
}
}
catch (Exception ex)
{
Console.Error.WriteLine($"[ErrorWindow] Failed to open folder: {ex.Message}");
}
}
}
/// <summary>
/// 错误窗口用户选择结果
/// </summary>
public enum ErrorWindowResult
{
/// <summary>
/// 重试
/// </summary>
Retry,
/// <summary>
/// 退出
/// </summary>
Exit
}

View File

@@ -0,0 +1,234 @@
<Window xmlns="https://github.com/avaloniaui"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
xmlns:d="http://schemas.microsoft.com/expression/blend/2008"
xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006"
xmlns:views="clr-namespace:LanMountainDesktop.Launcher.Views"
mc:Ignorable="d"
d:DesignWidth="600"
d:DesignHeight="500"
x:Class="LanMountainDesktop.Launcher.Views.LoadingDetailsWindow"
Title="LanMountain Desktop - Loading Details"
Width="600"
Height="500"
WindowStartupLocation="CenterScreen"
CanResize="True"
MinWidth="500"
MinHeight="400"
Background="{DynamicResource SolidBackgroundFillColorBaseBrush}"
Icon="/Assets/logo.ico">
<Grid RowDefinitions="Auto,*,Auto,Auto">
<Border Grid.Row="0"
Background="{DynamicResource CardBackgroundFillColorDefaultBrush}"
Padding="20,16">
<Grid ColumnDefinitions="*,Auto">
<StackPanel Grid.Column="0" Spacing="4">
<TextBlock Text="Starting LanMountain Desktop"
FontSize="18"
FontWeight="SemiBold"
Foreground="{DynamicResource TextFillColorPrimaryBrush}"/>
<TextBlock x:Name="SubtitleText"
Text="Initializing..."
FontSize="13"
Foreground="{DynamicResource TextFillColorSecondaryBrush}"/>
</StackPanel>
<Border Grid.Column="1"
Background="{DynamicResource AccentFillColorDefaultBrush}"
CornerRadius="12"
Padding="12,6"
VerticalAlignment="Center">
<TextBlock x:Name="PercentText"
Text="0%"
FontSize="16"
FontWeight="Bold"
Foreground="White"/>
</Border>
</Grid>
</Border>
<Grid Grid.Row="1" Margin="16,12">
<Grid.RowDefinitions>
<RowDefinition Height="Auto"/>
<RowDefinition Height="Auto"/>
<RowDefinition Height="*"/>
</Grid.RowDefinitions>
<ProgressBar x:Name="OverallProgressBar"
Grid.Row="0"
Height="8"
Minimum="0"
Maximum="100"
Value="0"
CornerRadius="4"
Margin="0,0,0,16"/>
<Border Grid.Row="1"
Background="{DynamicResource CardBackgroundFillColorSecondaryBrush}"
CornerRadius="8"
Padding="16,12"
Margin="0,0,0,12">
<Grid RowDefinitions="Auto,Auto,Auto" ColumnDefinitions="Auto,*">
<Border Grid.Row="0" Grid.RowSpan="3" Grid.Column="0"
Width="40"
Height="40"
CornerRadius="20"
Background="{DynamicResource AccentFillColorDefaultBrush}"
Margin="0,0,12,0"
VerticalAlignment="Center">
<TextBlock x:Name="CurrentItemIcon"
Text="&#xE768;"
FontSize="20"
FontFamily="{DynamicResource SymbolThemeFontFamily}"
Foreground="White"
HorizontalAlignment="Center"
VerticalAlignment="Center"/>
</Border>
<TextBlock x:Name="CurrentItemName"
Grid.Row="0" Grid.Column="1"
Text="Initializing..."
FontSize="15"
FontWeight="SemiBold"
Foreground="{DynamicResource TextFillColorPrimaryBrush}"/>
<TextBlock x:Name="CurrentItemDescription"
Grid.Row="1" Grid.Column="1"
Text="Preparing components"
FontSize="13"
Foreground="{DynamicResource TextFillColorSecondaryBrush}"
Margin="0,4,0,0"/>
<Grid Grid.Row="2" Grid.Column="1" Margin="0,8,0,0">
<ProgressBar x:Name="CurrentItemProgress"
Height="4"
Minimum="0"
Maximum="100"
Value="0"
CornerRadius="2"/>
</Grid>
</Grid>
</Border>
<Border Grid.Row="2"
Background="{DynamicResource CardBackgroundFillColorDefaultBrush}"
CornerRadius="8">
<Grid RowDefinitions="Auto,*">
<Grid Grid.Row="0" Margin="12,8" ColumnDefinitions="*,Auto,Auto">
<TextBlock Grid.Column="0"
Text="Loading Items"
FontSize="12"
FontWeight="SemiBold"
Foreground="{DynamicResource TextFillColorTertiaryBrush}"/>
<TextBlock x:Name="CompletedCountText"
Grid.Column="1"
Text="0"
FontSize="12"
Foreground="{DynamicResource TextFillColorSecondaryBrush}"
Margin="0,0,4,0"/>
<TextBlock Grid.Column="2"
Text="Done"
FontSize="12"
Foreground="{DynamicResource TextFillColorTertiaryBrush}"/>
</Grid>
<ScrollViewer Grid.Row="1"
VerticalScrollBarVisibility="Auto"
Margin="8,0,8,8">
<ItemsControl x:Name="LoadingItemsList">
<ItemsControl.ItemTemplate>
<DataTemplate DataType="views:LoadingItemViewModel">
<Grid ColumnDefinitions="Auto,*,Auto,Auto"
Margin="4,3"
Opacity="{Binding Opacity}">
<TextBlock Grid.Column="0"
Text="{Binding StatusIcon}"
FontSize="14"
FontFamily="{DynamicResource SymbolThemeFontFamily}"
Foreground="{Binding StatusColor}"
Margin="0,0,8,0"
VerticalAlignment="Center"/>
<TextBlock Grid.Column="1"
Text="{Binding Name}"
FontSize="13"
Foreground="{DynamicResource TextFillColorPrimaryBrush}"
TextTrimming="CharacterEllipsis"
VerticalAlignment="Center"/>
<TextBlock Grid.Column="2"
Text="{Binding ProgressText}"
FontSize="12"
Foreground="{DynamicResource TextFillColorSecondaryBrush}"
Margin="8,0"
VerticalAlignment="Center"/>
<Border Grid.Column="3"
Background="{Binding TypeBackground}"
CornerRadius="4"
Padding="6,2"
VerticalAlignment="Center">
<TextBlock Text="{Binding TypeLabel}"
FontSize="11"
Foreground="{Binding TypeForeground}"/>
</Border>
</Grid>
</DataTemplate>
</ItemsControl.ItemTemplate>
</ItemsControl>
</ScrollViewer>
</Grid>
</Border>
</Grid>
<Border x:Name="ErrorPanel"
Grid.Row="2"
Background="{DynamicResource SystemFillColorCriticalBackgroundBrush}"
BorderBrush="{DynamicResource SystemFillColorCriticalBrush}"
BorderThickness="1"
CornerRadius="8"
Padding="12,10"
Margin="16,0,16,12"
IsVisible="False">
<Grid ColumnDefinitions="Auto,*">
<TextBlock Grid.Column="0"
Text="&#xE783;"
FontSize="16"
FontFamily="{DynamicResource SymbolThemeFontFamily}"
Foreground="{DynamicResource SystemFillColorCriticalBrush}"
Margin="0,0,8,0"
VerticalAlignment="Center"/>
<TextBlock x:Name="ErrorText"
Grid.Column="1"
Text="An error occurred while loading."
FontSize="13"
Foreground="{DynamicResource SystemFillColorCriticalBrush}"
TextWrapping="Wrap"/>
</Grid>
</Border>
<Border Grid.Row="3"
Background="{DynamicResource CardBackgroundFillColorDefaultBrush}"
Padding="16,12">
<Grid ColumnDefinitions="*,Auto">
<TextBlock x:Name="VersionText"
Grid.Column="0"
Text="v1.0.0"
FontSize="12"
Foreground="{DynamicResource TextFillColorTertiaryBrush}"
VerticalAlignment="Center"/>
<StackPanel Grid.Column="1" Orientation="Horizontal" Spacing="8">
<Button x:Name="DetailsButton"
Content="Details"
Width="90"
Height="32"
FontSize="13"/>
<Button x:Name="CancelButton"
Content="Cancel"
Width="90"
Height="32"
FontSize="13"/>
</StackPanel>
</Grid>
</Border>
</Grid>
</Window>

View File

@@ -0,0 +1,396 @@
using Avalonia.Controls;
using Avalonia.Interactivity;
using Avalonia.Markup.Xaml;
using Avalonia.Media;
using Avalonia.Threading;
using LanMountainDesktop.Launcher.Services;
using LanMountainDesktop.Shared.Contracts.Launcher;
using System.Collections.ObjectModel;
using System.ComponentModel;
namespace LanMountainDesktop.Launcher.Views;
/// <summary>
/// 加载详情窗口 - 显示详细的加载状态和进度
/// </summary>
public partial class LoadingDetailsWindow : Window
{
private readonly ObservableCollection<LoadingItemViewModel> _items = new();
private readonly DispatcherTimer _updateTimer;
private DateTimeOffset _startTime;
public LoadingDetailsWindow()
{
AvaloniaXamlLoader.Load(this);
// 初始化列表
var itemsList = this.FindControl<ItemsControl>("LoadingItemsList");
if (itemsList != null)
{
itemsList.ItemsSource = _items;
}
// 创建更新定时器
_updateTimer = new DispatcherTimer
{
Interval = TimeSpan.FromMilliseconds(100)
};
_updateTimer.Tick += OnUpdateTimerTick;
_startTime = DateTimeOffset.UtcNow;
}
/// <summary>
/// 窗口加载完成
/// </summary>
protected override void OnLoaded(RoutedEventArgs e)
{
base.OnLoaded(e);
_updateTimer.Start();
}
/// <summary>
/// 窗口关闭
/// </summary>
protected override void OnClosing(WindowClosingEventArgs e)
{
_updateTimer.Stop();
base.OnClosing(e);
}
/// <summary>
/// 更新加载状态
/// </summary>
public void UpdateLoadingState(LoadingStateMessage state)
{
Dispatcher.UIThread.Post(() =>
{
try
{
// 更新标题和副标题
UpdateHeader(state);
// 更新整体进度
UpdateOverallProgress(state);
// 更新当前活动项
UpdateCurrentItem(state);
// 更新列表
UpdateItemsList(state);
// 更新错误信息
UpdateErrorPanel(state);
// 更新完成计数
UpdateCompletedCount(state);
}
catch (Exception ex)
{
Console.Error.WriteLine($"[LoadingDetailsWindow] Error updating state: {ex.Message}");
}
});
}
/// <summary>
/// 更新标题
/// </summary>
private void UpdateHeader(LoadingStateMessage state)
{
var subtitleText = this.FindControl<TextBlock>("SubtitleText");
if (subtitleText != null)
{
subtitleText.Text = GetStageDescription(state.Stage);
}
}
/// <summary>
/// 更新整体进度
/// </summary>
private void UpdateOverallProgress(LoadingStateMessage state)
{
var progressBar = this.FindControl<ProgressBar>("OverallProgressBar");
var percentText = this.FindControl<TextBlock>("PercentText");
if (progressBar != null)
{
progressBar.Value = state.OverallProgressPercent;
}
if (percentText != null)
{
percentText.Text = $"{state.OverallProgressPercent}%";
}
}
/// <summary>
/// 更新当前活动项
/// </summary>
private void UpdateCurrentItem(LoadingStateMessage state)
{
var currentItem = state.ActiveItems.FirstOrDefault();
if (currentItem == null) return;
var nameText = this.FindControl<TextBlock>("CurrentItemName");
var descText = this.FindControl<TextBlock>("CurrentItemDescription");
var progressBar = this.FindControl<ProgressBar>("CurrentItemProgress");
var iconText = this.FindControl<TextBlock>("CurrentItemIcon");
if (nameText != null)
{
nameText.Text = currentItem.Name;
}
if (descText != null)
{
descText.Text = currentItem.Message ?? GetItemDescription(currentItem);
}
if (progressBar != null)
{
progressBar.Value = currentItem.ProgressPercent;
}
if (iconText != null)
{
iconText.Text = GetItemIcon(currentItem.Type);
}
}
/// <summary>
/// 更新列表
/// </summary>
private void UpdateItemsList(LoadingStateMessage state)
{
// 同步列表项
foreach (var item in state.ActiveItems)
{
var existing = _items.FirstOrDefault(i => i.Id == item.Id);
if (existing != null)
{
existing.UpdateFrom(item);
}
else
{
_items.Add(new LoadingItemViewModel(item));
}
}
// 移除已完成的项保留最近完成的5个
var completedItems = _items.Where(i => i.State == LoadingState.Completed).ToList();
if (completedItems.Count > 5)
{
var itemsToRemove = completedItems.OrderBy(i => i.CompletedTime).Take(completedItems.Count - 5);
foreach (var item in itemsToRemove)
{
_items.Remove(item);
}
}
// 按状态排序:进行中 -> 等待中 -> 已完成 -> 失败
var sortedItems = _items.OrderBy(i => GetStatePriority(i.State)).ToList();
_items.Clear();
foreach (var item in sortedItems)
{
_items.Add(item);
}
}
/// <summary>
/// 更新错误面板
/// </summary>
private void UpdateErrorPanel(LoadingStateMessage state)
{
var errorPanel = this.FindControl<Border>("ErrorPanel");
var errorText = this.FindControl<TextBlock>("ErrorText");
if (errorPanel != null)
{
errorPanel.IsVisible = state.HasErrors;
}
if (errorText != null && state.ErrorMessages?.Any() == true)
{
errorText.Text = string.Join("\n", state.ErrorMessages.Take(3));
}
}
/// <summary>
/// 更新完成计数
/// </summary>
private void UpdateCompletedCount(LoadingStateMessage state)
{
var countText = this.FindControl<TextBlock>("CompletedCountText");
if (countText != null)
{
countText.Text = state.CompletedCount.ToString();
}
}
/// <summary>
/// 定时更新
/// </summary>
private void OnUpdateTimerTick(object? sender, EventArgs e)
{
// 可以在这里添加时间显示等实时更新
}
/// <summary>
/// 获取阶段描述
/// </summary>
private static string GetStageDescription(StartupStage stage) => stage switch
{
StartupStage.Initializing => "正在初始化系统...",
StartupStage.LoadingSettings => "正在加载设置...",
StartupStage.LoadingPlugins => "正在加载插件...",
StartupStage.InitializingUI => "正在初始化界面...",
StartupStage.Ready => "加载完成",
_ => "正在加载..."
};
/// <summary>
/// 获取项描述
/// </summary>
private static string GetItemDescription(LoadingItem item)
{
if (!string.IsNullOrEmpty(item.Description))
return item.Description;
return item.Type switch
{
LoadingItemType.Plugin => "正在加载插件...",
LoadingItemType.Component => "正在加载组件...",
LoadingItemType.Resource => "正在加载资源...",
LoadingItemType.Data => "正在加载数据...",
LoadingItemType.Network => "正在下载...",
_ => "正在处理..."
};
}
/// <summary>
/// 获取项图标
/// </summary>
private static string GetItemIcon(LoadingItemType type) => type switch
{
LoadingItemType.Plugin => "\uE768",
LoadingItemType.Component => "\uE7C4",
LoadingItemType.Resource => "\uE7C5",
LoadingItemType.Data => "\uE7C6",
LoadingItemType.Network => "\uE774",
LoadingItemType.Settings => "\uE713",
LoadingItemType.System => "\uE7C7",
_ => "\uE768"
};
/// <summary>
/// 获取状态优先级
/// </summary>
private static int GetStatePriority(LoadingState state) => state switch
{
LoadingState.InProgress => 0,
LoadingState.Pending => 1,
LoadingState.Completed => 2,
LoadingState.Failed => 3,
LoadingState.Timeout => 4,
LoadingState.Cancelled => 5,
_ => 6
};
}
/// <summary>
/// 加载项视图模型
/// </summary>
public class LoadingItemViewModel : INotifyPropertyChanged
{
public string Id { get; }
public string Name { get; private set; }
public LoadingItemType Type { get; private set; }
public LoadingState State { get; private set; }
public int ProgressPercent { get; private set; }
public DateTimeOffset? CompletedTime { get; private set; }
public string StatusIcon => GetStatusIcon(State);
public IBrush StatusColor => GetStatusColor(State);
public string ProgressText => State == LoadingState.Completed ? "完成" : $"{ProgressPercent}%";
public string TypeLabel => GetTypeLabel(Type);
public IBrush TypeBackground => GetTypeBackground(Type);
public IBrush TypeForeground => GetTypeForeground(Type);
public double Opacity => State == LoadingState.Completed ? 0.6 : 1.0;
public event PropertyChangedEventHandler? PropertyChanged;
public LoadingItemViewModel(LoadingItem item)
{
Id = item.Id;
UpdateFrom(item);
}
public void UpdateFrom(LoadingItem item)
{
Name = item.Name;
Type = item.Type;
State = item.State;
ProgressPercent = item.ProgressPercent;
if (State == LoadingState.Completed && !CompletedTime.HasValue)
{
CompletedTime = DateTimeOffset.UtcNow;
}
PropertyChanged?.Invoke(this, new PropertyChangedEventArgs(string.Empty));
}
private static string GetStatusIcon(LoadingState state) => state switch
{
LoadingState.Pending => "\uE7C3",
LoadingState.InProgress => "\uE768",
LoadingState.Completed => "\uE73E",
LoadingState.Failed => "\uE783",
LoadingState.Timeout => "\uE71A",
LoadingState.Cancelled => "\uE711",
_ => "\uE7C3"
};
private static IBrush GetStatusColor(LoadingState state) => state switch
{
LoadingState.Pending => new SolidColorBrush(Colors.Gray),
LoadingState.InProgress => new SolidColorBrush(Colors.DodgerBlue),
LoadingState.Completed => new SolidColorBrush(Colors.Green),
LoadingState.Failed => new SolidColorBrush(Colors.Red),
LoadingState.Timeout => new SolidColorBrush(Colors.Orange),
LoadingState.Cancelled => new SolidColorBrush(Colors.Gray),
_ => new SolidColorBrush(Colors.Gray)
};
private static string GetTypeLabel(LoadingItemType type) => type switch
{
LoadingItemType.Plugin => "插件",
LoadingItemType.Component => "组件",
LoadingItemType.Resource => "资源",
LoadingItemType.Data => "数据",
LoadingItemType.Network => "网络",
LoadingItemType.Settings => "设置",
LoadingItemType.System => "系统",
_ => "其他"
};
private static IBrush GetTypeBackground(LoadingItemType type) => type switch
{
LoadingItemType.Plugin => new SolidColorBrush(Color.Parse("#E3F2FD")),
LoadingItemType.Component => new SolidColorBrush(Color.Parse("#F3E5F5")),
LoadingItemType.Resource => new SolidColorBrush(Color.Parse("#E8F5E9")),
LoadingItemType.Data => new SolidColorBrush(Color.Parse("#FFF3E0")),
LoadingItemType.Network => new SolidColorBrush(Color.Parse("#E0F7FA")),
_ => new SolidColorBrush(Color.Parse("#F5F5F5"))
};
private static IBrush GetTypeForeground(LoadingItemType type) => type switch
{
LoadingItemType.Plugin => new SolidColorBrush(Color.Parse("#1976D2")),
LoadingItemType.Component => new SolidColorBrush(Color.Parse("#7B1FA2")),
LoadingItemType.Resource => new SolidColorBrush(Color.Parse("#388E3C")),
LoadingItemType.Data => new SolidColorBrush(Color.Parse("#F57C00")),
LoadingItemType.Network => new SolidColorBrush(Color.Parse("#0097A7")),
_ => new SolidColorBrush(Color.Parse("#616161"))
};
}

View File

@@ -0,0 +1,149 @@
<Window xmlns="https://github.com/avaloniaui"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
xmlns:d="http://schemas.microsoft.com/expression/blend/2008"
xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006"
xmlns:views="clr-namespace:LanMountainDesktop.Launcher.Views"
mc:Ignorable="d"
d:DesignWidth="520"
d:DesignHeight="360"
x:Class="LanMountainDesktop.Launcher.Views.MigrationPromptWindow"
x:DataType="views:MigrationPromptWindow"
Title="阑山桌面 - 版本迁移"
Width="520"
Height="360"
CanResize="False"
WindowStartupLocation="CenterScreen"
Background="{DynamicResource SolidBackgroundFillColorBaseBrush}"
TransparencyLevelHint="None"
Icon="/Assets/logo.ico">
<Design.DataContext>
<views:MigrationPromptWindow />
</Design.DataContext>
<Grid RowDefinitions="*,Auto">
<!-- 主内容区域 -->
<Grid Grid.Row="0" Margin="24,24,24,16" ColumnDefinitions="Auto,*">
<!-- 左侧:信息图标 -->
<Border Grid.Column="0"
Width="48"
Height="48"
Margin="0,4,16,0"
Background="{DynamicResource SystemFillColorCautionBackgroundBrush}"
CornerRadius="24"
VerticalAlignment="Top">
<TextBlock Text="&#xE7BA;"
FontSize="24"
FontFamily="{DynamicResource SymbolThemeFontFamily}"
Foreground="{DynamicResource SystemFillColorCautionBrush}"
HorizontalAlignment="Center"
VerticalAlignment="Center"/>
</Border>
<!-- 右侧:内容 -->
<StackPanel Grid.Column="1" Spacing="12">
<!-- 标题 -->
<TextBlock Text="检测到旧版本"
FontSize="18"
FontWeight="SemiBold"
Foreground="{DynamicResource TextFillColorPrimaryBrush}"
TextWrapping="Wrap"/>
<!-- 说明文字 -->
<TextBlock x:Name="DescriptionText"
Text="检测到您的系统中安装了旧版本的阑山桌面0.8.4)。新版本采用了全新的架构,建议卸载旧版本以获得更好的体验。"
FontSize="14"
Foreground="{DynamicResource TextFillColorSecondaryBrush}"
TextWrapping="Wrap"
LineHeight="20"/>
<!-- 老版本信息卡片 -->
<Border Margin="0,8,0,0"
Background="{DynamicResource CardBackgroundFillColorDefaultBrush}"
CornerRadius="8"
Padding="16,12">
<Grid RowDefinitions="Auto,Auto,Auto" ColumnDefinitions="Auto,*">
<!-- 版本号 -->
<TextBlock Grid.Row="0" Grid.Column="0"
Text="版本:"
FontSize="12"
Foreground="{DynamicResource TextFillColorTertiaryBrush}"/>
<TextBlock x:Name="VersionText"
Grid.Row="0" Grid.Column="1"
Text="0.8.4"
FontSize="12"
Foreground="{DynamicResource TextFillColorSecondaryBrush}"
Margin="8,0,0,0"/>
<!-- 安装路径 -->
<TextBlock Grid.Row="1" Grid.Column="0"
Text="位置:"
FontSize="12"
Foreground="{DynamicResource TextFillColorTertiaryBrush}"
Margin="0,4,0,0"/>
<TextBlock x:Name="PathText"
Grid.Row="1" Grid.Column="1"
Text="C:\Program Files\LanMountainDesktop"
FontSize="12"
Foreground="{DynamicResource TextFillColorSecondaryBrush}"
TextTrimming="CharacterEllipsis"
Margin="8,4,0,0"/>
<!-- 安装类型 -->
<TextBlock Grid.Row="2" Grid.Column="0"
Text="类型:"
FontSize="12"
Foreground="{DynamicResource TextFillColorTertiaryBrush}"
Margin="0,4,0,0"/>
<TextBlock x:Name="TypeText"
Grid.Row="2" Grid.Column="1"
Text="安装版"
FontSize="12"
Foreground="{DynamicResource TextFillColorSecondaryBrush}"
Margin="8,4,0,0"/>
</Grid>
</Border>
<!-- 提示信息 -->
<TextBlock Text="卸载旧版本不会影响新版本的使用,您的个人数据将保留。"
FontSize="12"
Foreground="{DynamicResource TextFillColorTertiaryBrush}"
TextWrapping="Wrap"
Margin="0,4,0,0"/>
</StackPanel>
</Grid>
<!-- 底部:按钮区域 -->
<Border Grid.Row="1"
Background="{DynamicResource CardBackgroundFillColorDefaultBrush}"
Padding="24,16">
<Grid ColumnDefinitions="*,Auto">
<!-- 左侧:查看位置按钮 -->
<Button x:Name="ShowLocationButton"
Grid.Column="0"
Content="查看位置"
Width="100"
Height="32"
FontSize="13"
HorizontalAlignment="Left"/>
<!-- 右侧:操作按钮 -->
<StackPanel Grid.Column="1"
Orientation="Horizontal"
Spacing="8">
<Button x:Name="SkipButton"
Content="暂不处理"
Width="100"
Height="32"
FontSize="13"/>
<Button x:Name="UninstallButton"
Content="卸载旧版本"
Width="100"
Height="32"
FontSize="13"
Theme="{DynamicResource AccentButtonTheme}"/>
</StackPanel>
</Grid>
</Border>
</Grid>
</Window>

View File

@@ -0,0 +1,157 @@
using Avalonia.Controls;
using Avalonia.Interactivity;
using Avalonia.Markup.Xaml;
using LanMountainDesktop.Launcher.Services;
namespace LanMountainDesktop.Launcher.Views;
/// <summary>
/// 迁移提示窗口 - 提示用户卸载旧版本
/// </summary>
public partial class MigrationPromptWindow : Window
{
private readonly TaskCompletionSource<MigrationResult> _completionSource = new();
private LegacyVersionInfo? _legacyInfo;
public MigrationPromptWindow()
{
AvaloniaXamlLoader.Load(this);
InitializeEventHandlers();
}
/// <summary>
/// 设置老版本信息
/// </summary>
public void SetLegacyInfo(LegacyVersionInfo info)
{
_legacyInfo = info;
// 更新 UI
var versionText = this.FindControl<TextBlock>("VersionText");
var pathText = this.FindControl<TextBlock>("PathText");
var typeText = this.FindControl<TextBlock>("TypeText");
var descriptionText = this.FindControl<TextBlock>("DescriptionText");
if (versionText != null)
{
versionText.Text = info.Version;
}
if (pathText != null)
{
pathText.Text = info.InstallPath;
}
if (typeText != null)
{
typeText.Text = info.InstallType switch
{
LegacyInstallType.Registry => "安装版",
LegacyInstallType.Portable => "便携版",
_ => "未知"
};
}
if (descriptionText != null)
{
descriptionText.Text = $"检测到您的系统中安装了旧版本的阑山桌面({info.Version})。新版本采用了全新的架构,建议卸载旧版本以获得更好的体验。";
}
}
/// <summary>
/// 初始化事件处理程序
/// </summary>
private void InitializeEventHandlers()
{
var showLocationButton = this.FindControl<Button>("ShowLocationButton");
var skipButton = this.FindControl<Button>("SkipButton");
var uninstallButton = this.FindControl<Button>("UninstallButton");
if (showLocationButton != null)
{
showLocationButton.Click += OnShowLocationClick;
}
if (skipButton != null)
{
skipButton.Click += OnSkipClick;
}
if (uninstallButton != null)
{
uninstallButton.Click += OnUninstallClick;
}
}
/// <summary>
/// 查看位置按钮点击
/// </summary>
private void OnShowLocationClick(object? sender, RoutedEventArgs e)
{
if (_legacyInfo != null)
{
LegacyVersionDetector.ShowInExplorer(_legacyInfo.InstallPath);
}
}
/// <summary>
/// 跳过按钮点击
/// </summary>
private void OnSkipClick(object? sender, RoutedEventArgs e)
{
_completionSource.TrySetResult(MigrationResult.Skipped);
Close();
}
/// <summary>
/// 卸载按钮点击
/// </summary>
private void OnUninstallClick(object? sender, RoutedEventArgs e)
{
if (_legacyInfo != null)
{
LegacyVersionDetector.OpenUninstallInterface(_legacyInfo);
}
_completionSource.TrySetResult(MigrationResult.UninstallOpened);
Close();
}
/// <summary>
/// 等待用户选择
/// </summary>
public Task<MigrationResult> WaitForChoiceAsync()
{
return _completionSource.Task;
}
/// <summary>
/// 窗口关闭事件
/// </summary>
protected override void OnClosing(WindowClosingEventArgs e)
{
// 如果还没有完成,标记为跳过
if (!_completionSource.Task.IsCompleted)
{
_completionSource.TrySetResult(MigrationResult.Skipped);
}
base.OnClosing(e);
}
}
/// <summary>
/// 迁移结果
/// </summary>
public enum MigrationResult
{
/// <summary>
/// 用户选择跳过
/// </summary>
Skipped,
/// <summary>
/// 已打开卸载界面
/// </summary>
UninstallOpened
}

View File

@@ -0,0 +1,76 @@
<Window xmlns="https://github.com/avaloniaui"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
xmlns:d="http://schemas.microsoft.com/expression/blend/2008"
xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006"
xmlns:views="clr-namespace:LanMountainDesktop.Launcher.Views"
xmlns:ui="using:FluentAvalonia.UI.Controls"
mc:Ignorable="d"
d:DesignWidth="600"
d:DesignHeight="500"
x:Class="LanMountainDesktop.Launcher.Views.OobeWindow"
x:DataType="views:OobeWindow"
Title="欢迎使用阑山桌面"
Width="600"
Height="500"
CanResize="False"
WindowStartupLocation="CenterScreen"
Background="{DynamicResource SolidBackgroundFillColorBaseBrush}"
TransparencyLevelHint="None"
Icon="/Assets/logo.ico">
<Design.DataContext>
<views:OobeWindow />
</Design.DataContext>
<Grid x:Name="ContentGrid">
<!-- 主内容区域 -->
<Grid Margin="48" RowDefinitions="*,Auto">
<!-- 中央内容区域 -->
<StackPanel Grid.Row="0"
VerticalAlignment="Center"
HorizontalAlignment="Center"
Spacing="24">
<!-- 顶部:完成状态勾号图标 -->
<Border Width="80"
Height="80"
Background="{DynamicResource SystemFillColorSuccessBackgroundBrush}"
CornerRadius="40"
HorizontalAlignment="Center">
<ui:SymbolIcon Symbol="Accept"
FontSize="40"
Foreground="{DynamicResource SystemFillColorSuccessBrush}"
HorizontalAlignment="Center"
VerticalAlignment="Center"/>
</Border>
<!-- 中央:欢迎文字 -->
<StackPanel Spacing="8" HorizontalAlignment="Center">
<TextBlock Text="欢迎使用阑山桌面"
FontSize="28"
FontWeight="SemiBold"
Foreground="{DynamicResource TextFillColorPrimaryBrush}"
HorizontalAlignment="Center" />
<TextBlock Text="你的桌面,不止一面"
FontSize="14"
Foreground="{DynamicResource TextFillColorSecondaryBrush}"
HorizontalAlignment="Center" />
</StackPanel>
</StackPanel>
<!-- 底部:圆形开始按钮 -->
<Button Grid.Row="1"
x:Name="EnterButton"
HorizontalAlignment="Center"
Width="56"
Height="56"
Margin="0,0,0,16"
Theme="{DynamicResource AccentButtonTheme}"
CornerRadius="28">
<ui:SymbolIcon Symbol="Forward"
FontSize="24"
Foreground="{DynamicResource TextOnAccentFillColorPrimaryBrush}"/>
</Button>
</Grid>
</Grid>
</Window>

View File

@@ -0,0 +1,197 @@
using Avalonia;
using Avalonia.Animation;
using Avalonia.Animation.Easings;
using Avalonia.Controls;
using Avalonia.Interactivity;
using Avalonia.Markup.Xaml;
using Avalonia.Media;
using Avalonia.Styling;
namespace LanMountainDesktop.Launcher.Views;
/// <summary>
/// OOBE首次使用体验窗口 - 欢迎页面
/// </summary>
public partial class OobeWindow : Window
{
private readonly TaskCompletionSource<bool> _completionSource = new();
private bool _isTransitioning = false;
public OobeWindow()
{
AvaloniaXamlLoader.Load(this);
// 延迟到窗口加载完成后再初始化
this.Loaded += OnWindowLoaded;
this.Opened += OnWindowOpened;
}
/// <summary>
/// 窗口加载完成事件
/// </summary>
private void OnWindowLoaded(object? sender, RoutedEventArgs e)
{
Console.WriteLine("[OobeWindow] Window loaded, initializing components...");
var enterButton = this.FindControl<Button>("EnterButton");
if (enterButton is not null)
{
enterButton.Click += OnEnterClick;
Console.WriteLine("[OobeWindow] EnterButton event bound successfully");
}
else
{
Console.Error.WriteLine("[OobeWindow] Failed to find EnterButton!");
}
}
/// <summary>
/// 窗口打开事件 - 播放入场动画
/// </summary>
private async void OnWindowOpened(object? sender, EventArgs e)
{
Console.WriteLine("[OobeWindow] Window opened, playing entrance animation...");
await PlayEntranceAnimationAsync();
}
/// <summary>
/// 播放入场动画
/// </summary>
private async Task PlayEntranceAnimationAsync()
{
try
{
// 获取内容元素
var contentGrid = this.FindControl<Grid>("ContentGrid");
if (contentGrid is null)
{
// 如果没有命名网格,直接返回
return;
}
// 创建淡入动画
var fadeInAnimation = new Animation
{
Duration = TimeSpan.FromMilliseconds(600),
Easing = new CubicEaseOut(),
Children =
{
new KeyFrame
{
Setters = { new Setter(OpacityProperty, 0.0) },
KeyTime = TimeSpan.FromMilliseconds(0)
},
new KeyFrame
{
Setters = { new Setter(OpacityProperty, 1.0) },
KeyTime = TimeSpan.FromMilliseconds(600)
}
}
};
// 创建向上滑动动画
var slideUpAnimation = new Animation
{
Duration = TimeSpan.FromMilliseconds(600),
Easing = new CubicEaseOut(),
Children =
{
new KeyFrame
{
Setters = { new Setter(TranslateTransform.YProperty, 30.0) },
KeyTime = TimeSpan.FromMilliseconds(0)
},
new KeyFrame
{
Setters = { new Setter(TranslateTransform.YProperty, 0.0) },
KeyTime = TimeSpan.FromMilliseconds(600)
}
}
};
// 应用动画
await fadeInAnimation.RunAsync(contentGrid);
await slideUpAnimation.RunAsync(contentGrid);
Console.WriteLine("[OobeWindow] Entrance animation completed");
}
catch (Exception ex)
{
Console.Error.WriteLine($"[OobeWindow] Error playing entrance animation: {ex.Message}");
}
}
/// <summary>
/// 等待用户点击开始按钮
/// </summary>
public Task WaitForEnterAsync() => _completionSource.Task;
/// <summary>
/// 进入按钮点击事件
/// </summary>
private async void OnEnterClick(object? sender, RoutedEventArgs e)
{
if (_isTransitioning) return;
_isTransitioning = true;
Console.WriteLine("[OobeWindow] Enter button clicked, starting transition...");
try
{
// 播放退出动画
await PlayExitAnimationAsync();
// 完成 OOBE
_completionSource.TrySetResult(true);
}
catch (Exception ex)
{
Console.Error.WriteLine($"[OobeWindow] Error during transition: {ex.Message}");
_completionSource.TrySetResult(true);
}
}
/// <summary>
/// 播放退出动画
/// </summary>
private async Task PlayExitAnimationAsync()
{
try
{
var contentGrid = this.FindControl<Grid>("ContentGrid");
if (contentGrid is null)
{
// 如果没有命名网格,直接延迟后返回
await Task.Delay(200);
return;
}
// 创建淡出动画
var fadeOutAnimation = new Animation
{
Duration = TimeSpan.FromMilliseconds(200),
Easing = new CubicEaseIn(),
Children =
{
new KeyFrame
{
Setters = { new Setter(OpacityProperty, 1.0) },
KeyTime = TimeSpan.FromMilliseconds(0)
},
new KeyFrame
{
Setters = { new Setter(OpacityProperty, 0.0) },
KeyTime = TimeSpan.FromMilliseconds(200)
}
}
};
await fadeOutAnimation.RunAsync(contentGrid);
Console.WriteLine("[OobeWindow] Exit animation completed");
}
catch (Exception ex)
{
Console.Error.WriteLine($"[OobeWindow] Error playing exit animation: {ex.Message}");
}
}
}

View File

@@ -0,0 +1,87 @@
<Window xmlns="https://github.com/avaloniaui"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
xmlns:d="http://schemas.microsoft.com/expression/blend/2008"
xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006"
xmlns:views="clr-namespace:LanMountainDesktop.Launcher.Views"
xmlns:ui="using:FluentAvalonia.UI.Controls"
mc:Ignorable="d"
d:DesignWidth="480"
d:DesignHeight="320"
x:Class="LanMountainDesktop.Launcher.Views.SplashWindow"
x:DataType="views:SplashWindow"
Title="LanMountain Desktop"
Width="480"
Height="320"
CanResize="False"
WindowStartupLocation="CenterScreen"
SystemDecorations="None"
Background="{DynamicResource SolidBackgroundFillColorBaseBrush}"
TransparencyLevelHint="None"
Icon="/Assets/logo.ico">
<Design.DataContext>
<views:SplashWindow />
</Design.DataContext>
<Grid>
<!-- 左上角:应用名称 -->
<TextBlock x:Name="AppNameText"
Text="LanMountain Desktop"
FontSize="24"
FontWeight="SemiBold"
VerticalAlignment="Top"
HorizontalAlignment="Left"
Margin="24,24,0,0"
Foreground="{DynamicResource TextFillColorPrimaryBrush}" />
<!-- 底部区域:进度条和状态 -->
<Grid VerticalAlignment="Bottom" Margin="24,0,24,24">
<Grid.RowDefinitions>
<RowDefinition Height="Auto"/>
<RowDefinition Height="Auto"/>
</Grid.RowDefinitions>
<!-- 第一行:左下角版本信息,右下角阶段文字 -->
<Grid Grid.Row="0" Margin="0,0,0,8">
<Grid.ColumnDefinitions>
<ColumnDefinition Width="*"/>
<ColumnDefinition Width="Auto"/>
</Grid.ColumnDefinitions>
<!-- 左下角:版本和开发代号 - 可点击打开开发者界面(隐藏功能) -->
<Border x:Name="VersionTextBorder"
Grid.Column="0"
Background="Transparent"
Cursor="Hand"
HorizontalAlignment="Left"
VerticalAlignment="Bottom">
<TextBlock x:Name="VersionText"
FontSize="11"
Foreground="{DynamicResource TextFillColorSecondaryBrush}"
Opacity="0.8"
Text="1.0.0 (Administrate)" />
</Border>
<!-- 右下角:阶段文字 -->
<TextBlock x:Name="StatusText"
Grid.Column="1"
FontSize="11"
Foreground="{DynamicResource TextFillColorSecondaryBrush}"
HorizontalAlignment="Right"
VerticalAlignment="Bottom"
Opacity="0.8"
Text="Initializing..." />
</Grid>
<!-- 底部:进度条 -->
<ProgressBar x:Name="ProgressIndicator"
Grid.Row="1"
Minimum="0"
Maximum="100"
Value="0"
Height="4"
IsIndeterminate="False"
Foreground="{DynamicResource AccentFillColorDefaultBrush}"
Background="{DynamicResource ControlStrokeColorDefaultBrush}" />
</Grid>
</Grid>
</Window>

View File

@@ -0,0 +1,250 @@
using Avalonia.Controls;
using Avalonia.Input;
using Avalonia.Interactivity;
using Avalonia.Markup.Xaml;
using Avalonia.Threading;
using LanMountainDesktop.Launcher.Services;
namespace LanMountainDesktop.Launcher.Views;
/// <summary>
/// 启动画面窗口 - 简洁设计
/// </summary>
public partial class SplashWindow : Window, ISplashStageReporter
{
private int _versionTextClickCount = 0;
private const int DebugModeClickThreshold = 5;
private bool _isDebugModeOpened = false;
public SplashWindow()
{
AvaloniaXamlLoader.Load(this);
// 延迟到窗口加载完成后再绑定事件
this.Loaded += OnWindowLoaded;
}
/// <summary>
/// 窗口加载完成事件
/// </summary>
private void OnWindowLoaded(object? sender, RoutedEventArgs e)
{
Console.WriteLine("[SplashWindow] Window loaded, binding events...");
// 绑定版本文本点击事件隐藏功能点击5次打开开发者界面
var versionTextBorder = this.FindControl<Border>("VersionTextBorder");
if (versionTextBorder is not null)
{
versionTextBorder.PointerPressed += OnVersionTextClick;
Console.WriteLine("[SplashWindow] VersionTextBorder click event bound");
}
else
{
Console.Error.WriteLine("[SplashWindow] Failed to find VersionTextBorder!");
}
}
/// <summary>
/// 版本文本点击事件 - 连续点击5次打开开发者界面隐藏功能
/// </summary>
private void OnVersionTextClick(object? sender, PointerPressedEventArgs e)
{
if (_isDebugModeOpened) return;
_versionTextClickCount++;
Console.WriteLine($"[SplashWindow] Version text clicked {_versionTextClickCount}/{DebugModeClickThreshold}");
if (_versionTextClickCount >= DebugModeClickThreshold)
{
OpenDebugWindow();
}
}
/// <summary>
/// 打开开发者调试窗口
/// </summary>
private async void OpenDebugWindow()
{
_isDebugModeOpened = true;
Console.WriteLine("[SplashWindow] Opening debug window...");
try
{
// 加载保存的状态
var devModeEnabled = ErrorWindow.CheckDevModeEnabled();
var customHostPath = ErrorWindow.GetSavedCustomHostPath();
var debugWindow = new ErrorDebugWindow(devModeEnabled, customHostPath)
{
WindowStartupLocation = WindowStartupLocation.CenterScreen
};
// 订阅窗口关闭事件以保存状态
debugWindow.Closed += (s, e) =>
{
Console.WriteLine("[SplashWindow] Debug window closed");
_isDebugModeOpened = false;
_versionTextClickCount = 0;
};
await debugWindow.ShowDialog(this);
}
catch (Exception ex)
{
Console.Error.WriteLine($"[SplashWindow] Error opening debug window: {ex.Message}");
_isDebugModeOpened = false;
_versionTextClickCount = 0;
}
}
/// <summary>
/// 更新进度和状态
/// </summary>
public void Report(string stage, string message)
{
Dispatcher.UIThread.Post(() =>
{
var statusText = this.FindControl<TextBlock>("StatusText");
var progressIndicator = this.FindControl<ProgressBar>("ProgressIndicator");
if (statusText is null || progressIndicator is null)
{
Console.Error.WriteLine($"[SplashWindow] Controls not found: StatusText={statusText != null}, ProgressIndicator={progressIndicator != null}");
return;
}
// 更新状态文本
statusText.Text = message;
// 根据阶段更新进度
var progress = ResolveProgress(stage);
if (progress > 0)
{
progressIndicator.IsIndeterminate = false;
progressIndicator.Value = progress;
}
else
{
progressIndicator.IsIndeterminate = true;
}
});
}
/// <summary>
/// 更新进度0-100
/// </summary>
public void UpdateProgress(int percent, string? message = null)
{
Dispatcher.UIThread.Post(() =>
{
var statusText = this.FindControl<TextBlock>("StatusText");
var progressIndicator = this.FindControl<ProgressBar>("ProgressIndicator");
if (statusText is null || progressIndicator is null)
{
Console.Error.WriteLine($"[SplashWindow] Controls not found in UpdateProgress");
return;
}
if (!string.IsNullOrEmpty(message))
{
statusText.Text = message;
}
progressIndicator.IsIndeterminate = false;
progressIndicator.Value = Math.Clamp(percent, 0, 100);
});
}
/// <summary>
/// 更新状态文本
/// </summary>
public void UpdateStatus(string message)
{
Dispatcher.UIThread.Post(() =>
{
var statusText = this.FindControl<TextBlock>("StatusText");
if (statusText is null)
{
Console.Error.WriteLine($"[SplashWindow] StatusText not found in UpdateStatus");
return;
}
statusText.Text = message;
});
}
/// <summary>
/// 报告阶段和进度0-100
/// </summary>
public void ReportStage(string stage, int progress)
{
Dispatcher.UIThread.Post(() =>
{
var statusText = this.FindControl<TextBlock>("StatusText");
var progressIndicator = this.FindControl<ProgressBar>("ProgressIndicator");
if (statusText is null || progressIndicator is null)
{
Console.Error.WriteLine($"[SplashWindow] Controls not found in ReportStage");
return;
}
statusText.Text = stage;
progressIndicator.IsIndeterminate = false;
progressIndicator.Value = Math.Clamp(progress, 0, 100);
});
}
/// <summary>
/// 设置版本和开发代号
/// </summary>
public void SetVersionInfo(string version, string codename)
{
Dispatcher.UIThread.Post(() =>
{
var versionText = this.FindControl<TextBlock>("VersionText");
if (versionText is null)
{
Console.Error.WriteLine($"[SplashWindow] VersionText not found in SetVersionInfo");
return;
}
versionText.Text = $"{version} ({codename})";
});
}
/// <summary>
/// 设置调试模式
/// </summary>
public void SetDebugMode(bool isDebugMode)
{
Dispatcher.UIThread.Post(() =>
{
var statusText = this.FindControl<TextBlock>("StatusText");
if (statusText is null)
{
Console.Error.WriteLine($"[SplashWindow] StatusText not found in SetDebugMode");
return;
}
if (isDebugMode)
{
statusText.Text = "[Debug Mode] Splash Preview";
}
});
}
/// <summary>
/// 根据阶段名称解析进度值
/// </summary>
private static int ResolveProgress(string stage)
{
return stage.ToLowerInvariant() switch
{
"initializing" => 10,
"update" => 30,
"plugins" => 50,
"launch" => 70,
"ready" => 100,
_ => 0
};
}
}

View File

@@ -0,0 +1,108 @@
<Window xmlns="https://github.com/avaloniaui"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
xmlns:d="http://schemas.microsoft.com/expression/blend/2008"
xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006"
xmlns:views="clr-namespace:LanMountainDesktop.Launcher.Views"
mc:Ignorable="d"
d:DesignWidth="480"
d:DesignHeight="320"
x:Class="LanMountainDesktop.Launcher.Views.UpdateWindow"
x:DataType="views:UpdateWindow"
Title="阑山桌面 - 更新"
Width="480"
Height="320"
CanResize="False"
WindowStartupLocation="CenterScreen"
SystemDecorations="None"
Background="{DynamicResource SolidBackgroundFillColorBaseBrush}"
TransparencyLevelHint="None"
Icon="/Assets/logo.ico">
<Design.DataContext>
<views:UpdateWindow />
</Design.DataContext>
<Grid>
<!-- 顶部:应用名称和最小化按钮 -->
<Grid VerticalAlignment="Top" Margin="24,24,24,0">
<StackPanel Orientation="Horizontal" HorizontalAlignment="Left" VerticalAlignment="Center" Spacing="8">
<TextBlock x:Name="TitleText"
Text="阑山桌面"
FontSize="24"
FontWeight="SemiBold"
Foreground="{DynamicResource TextFillColorPrimaryBrush}" />
<Border Background="{DynamicResource AccentFillColorDefaultBrush}"
CornerRadius="4"
Padding="6,2"
VerticalAlignment="Center">
<TextBlock Text="Update"
FontSize="11"
FontWeight="SemiBold"
Foreground="{DynamicResource TextOnAccentFillColorPrimaryBrush}" />
</Border>
</StackPanel>
<!-- 最小化按钮 -->
<Button x:Name="MinimizeButton"
HorizontalAlignment="Right"
VerticalAlignment="Center"
Width="32"
Height="32"
Background="Transparent"
BorderThickness="0">
<TextBlock Text="&#xE921;"
FontSize="12"
FontFamily="{DynamicResource SymbolThemeFontFamily}"
Foreground="{DynamicResource TextFillColorSecondaryBrush}"
HorizontalAlignment="Center"
VerticalAlignment="Center"/>
</Button>
</Grid>
<!-- 底部区域:进度条和状态 -->
<Grid VerticalAlignment="Bottom" Margin="24,0,24,24">
<Grid.RowDefinitions>
<RowDefinition Height="Auto"/>
<RowDefinition Height="Auto"/>
</Grid.RowDefinitions>
<!-- 第一行:左下角状态,右下角百分比 -->
<Grid Grid.Row="0" Margin="0,0,0,8">
<Grid.ColumnDefinitions>
<ColumnDefinition Width="*"/>
<ColumnDefinition Width="Auto"/>
</Grid.ColumnDefinitions>
<!-- 左下角:状态文字 -->
<TextBlock x:Name="StatusText"
Grid.Column="0"
FontSize="11"
Foreground="{DynamicResource TextFillColorSecondaryBrush}"
Opacity="0.8"
HorizontalAlignment="Left"
VerticalAlignment="Bottom"
Text="正在更新,请稍候..." />
<!-- 右下角:百分比 -->
<TextBlock x:Name="PercentText"
Grid.Column="1"
FontSize="11"
Foreground="{DynamicResource TextFillColorSecondaryBrush}"
Opacity="0.8"
HorizontalAlignment="Right"
VerticalAlignment="Bottom"
Text="0%" />
</Grid>
<!-- 底部:进度条 -->
<ProgressBar x:Name="ProgressIndicator"
Grid.Row="1"
Minimum="0"
Maximum="100"
Value="0"
Height="4"
IsIndeterminate="True"
Foreground="{DynamicResource AccentFillColorDefaultBrush}"
Background="{DynamicResource ControlStrokeColorDefaultBrush}" />
</Grid>
</Grid>
</Window>

View File

@@ -0,0 +1,123 @@
using Avalonia.Controls;
using Avalonia.Markup.Xaml;
using Avalonia.Threading;
namespace LanMountainDesktop.Launcher.Views;
/// <summary>
/// 更新进度窗口 - 用于 apply-update 命令模式下显示更新/插件升级进度
/// </summary>
public partial class UpdateWindow : Window
{
public UpdateWindow()
{
AvaloniaXamlLoader.Load(this);
InitializeEventHandlers();
}
/// <summary>
/// 初始化事件处理程序
/// </summary>
private void InitializeEventHandlers()
{
var minimizeButton = this.FindControl<Button>("MinimizeButton");
if (minimizeButton != null)
{
minimizeButton.Click += (s, e) =>
{
this.WindowState = WindowState.Minimized;
};
}
}
/// <summary>
/// 更新状态和进度
/// </summary>
public void Report(string stage, string message, int progressPercent = -1)
{
Dispatcher.UIThread.Post(() =>
{
var statusText = this.FindControl<TextBlock>("StatusText");
var progressIndicator = this.FindControl<ProgressBar>("ProgressIndicator");
var percentText = this.FindControl<TextBlock>("PercentText");
if (statusText is null || progressIndicator is null || percentText is null)
{
Console.Error.WriteLine($"[UpdateWindow] Controls not found in Report: StatusText={statusText != null}, ProgressIndicator={progressIndicator != null}, PercentText={percentText != null}");
return;
}
statusText.Text = message;
if (progressPercent >= 0)
{
progressIndicator.IsIndeterminate = false;
progressIndicator.Value = progressPercent;
percentText.Text = $"{progressPercent}%";
}
else
{
progressIndicator.IsIndeterminate = true;
percentText.Text = "";
}
});
}
/// <summary>
/// 显示更新完成状态
/// </summary>
public void ReportComplete(bool success, string? errorMessage = null)
{
Dispatcher.UIThread.Post(() =>
{
var statusText = this.FindControl<TextBlock>("StatusText");
var progressIndicator = this.FindControl<ProgressBar>("ProgressIndicator");
var percentText = this.FindControl<TextBlock>("PercentText");
var titleText = this.FindControl<TextBlock>("TitleText");
if (statusText is null || progressIndicator is null || percentText is null || titleText is null)
{
Console.Error.WriteLine($"[UpdateWindow] Controls not found in ReportComplete");
return;
}
progressIndicator.IsIndeterminate = false;
progressIndicator.Value = 100;
percentText.Text = "100%";
if (success)
{
statusText.Text = "更新完成";
}
else
{
titleText.Text = "更新失败";
statusText.Text = errorMessage ?? "更新过程中发生错误";
}
});
}
/// <summary>
/// 设置调试模式
/// </summary>
public void SetDebugMode(bool isDebugMode)
{
Dispatcher.UIThread.Post(() =>
{
var statusText = this.FindControl<TextBlock>("StatusText");
var titleText = this.FindControl<TextBlock>("TitleText");
if (statusText is null || titleText is null)
{
Console.Error.WriteLine($"[UpdateWindow] Controls not found in SetDebugMode");
return;
}
if (isDebugMode)
{
titleText.Text = "[调试模式] 更新页面";
statusText.Text = "预览更新进度界面";
}
});
}
}

View File

@@ -0,0 +1,25 @@
<?xml version="1.0" encoding="utf-8"?>
<assembly manifestVersion="1.0" xmlns="urn:schemas-microsoft-com:asm.v1">
<assemblyIdentity version="1.0.0.0" name="LanMountainDesktop.Launcher"/>
<trustInfo xmlns="urn:schemas-microsoft-com:asm.v2">
<security>
<requestedPrivileges xmlns="urn:schemas-microsoft-com:asm.v3">
<!-- 明确指定不需要管理员权限 -->
<requestedExecutionLevel level="asInvoker" uiAccess="false" />
</requestedPrivileges>
</security>
</trustInfo>
<compatibility xmlns="urn:schemas-microsoft-com:compatibility.v1">
<application>
<!-- Windows 10/11 -->
<supportedOS Id="{8e0f7a12-bfb3-4fe8-b9a5-48fd50a15a9a}" />
<!-- Windows 8.1 -->
<supportedOS Id="{1f676c76-80e1-4239-95bb-83d0f6d0da78}" />
<!-- Windows 8 -->
<supportedOS Id="{4a2f28e3-53b9-4441-ba9c-d69d4a4a6e38}" />
<!-- Windows 7 -->
<supportedOS Id="{35138b9a-5d96-4fbd-8e2d-a2440225f93a}" />
</application>
</compatibility>
</assembly>