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

This commit is contained in:
lincube
2026-04-17 15:16:01 +08:00
parent 59c4824425
commit 81ee19f360
49 changed files with 4175 additions and 468 deletions

View File

@@ -100,37 +100,46 @@ jobs:
-p:FileVersion=${{ needs.prepare.outputs.assembly_version }}
-p:InformationalVersion=${{ needs.prepare.outputs.informational_version }}
- name: Publish Launcher
- name: Publish Launcher (AOT)
run: |
$version = "${{ needs.prepare.outputs.version }}"
$arch = "${{ matrix.arch }}"
$selfContained = "${{ matrix.self_contained }}" -eq "true"
$launcherPublishDir = "publish/launcher-win-$arch"
if ($selfContained) {
dotnet publish LanMountainDesktop.Launcher/LanMountainDesktop.Launcher.csproj `
-c Release `
-o ./$launcherPublishDir `
--self-contained `
-r win-$arch `
-p:PublishSingleFile=false `
-p:PublishTrimmed=false `
-p:PublishReadyToRun=false `
-p:DebugType=none `
-p:DebugSymbols=false
} else {
dotnet publish LanMountainDesktop.Launcher/LanMountainDesktop.Launcher.csproj `
-c Release `
-o ./$launcherPublishDir `
--self-contained:false `
-p:PublishSingleFile=false `
-p:PublishTrimmed=false `
-p:PublishReadyToRun=false `
-p:DebugType=none `
-p:DebugSymbols=false
Write-Host "Publishing Launcher with AOT for Windows $arch..."
# AOT 单文件发布
dotnet publish LanMountainDesktop.Launcher/LanMountainDesktop.Launcher.csproj `
-c Release `
-o ./$launcherPublishDir `
--self-contained `
-r win-$arch `
-p:PublishAot=true `
-p:PublishSingleFile=true `
-p:IncludeNativeLibrariesForSelfExtract=true `
-p:EnableCompressionInSingleFile=true `
-p:DebugType=none `
-p:DebugSymbols=false
if ($LASTEXITCODE -ne 0) {
Write-Error "Launcher AOT publish failed"
exit 1
}
# 显示发布结果
Write-Host "Launcher published to: $launcherPublishDir"
$exeFile = Get-ChildItem -Path $launcherPublishDir -Filter "*.exe" | Select-Object -First 1
if ($exeFile) {
$size = [Math]::Round($exeFile.Length / 1MB, 2)
Write-Host "Launcher executable: $($exeFile.Name) ($size MB)"
}
# 清理不必要的文件AOT 单文件应该只有一个 exe
$files = Get-ChildItem -Path $launcherPublishDir -File
if ($files.Count -gt 1) {
Write-Host "Warning: Expected single file but found $($files.Count) files"
$files | ForEach-Object { Write-Host " - $($_.Name)" }
}
shell: pwsh
- name: Publish Main App
@@ -552,18 +561,29 @@ jobs:
-p:FileVersion=${{ needs.prepare.outputs.assembly_version }}
-p:InformationalVersion=${{ needs.prepare.outputs.informational_version }}
- name: Publish Launcher
- name: Publish Launcher (AOT)
run: |
echo "Publishing Launcher with AOT for Linux x64..."
dotnet publish LanMountainDesktop.Launcher/LanMountainDesktop.Launcher.csproj \
-c Release \
-o ./publish/launcher-linux-x64 \
--self-contained \
-r linux-x64 \
-p:PublishSingleFile=false \
-p:PublishTrimmed=false \
-p:PublishReadyToRun=false \
-p:PublishAot=true \
-p:PublishSingleFile=true \
-p:IncludeNativeLibrariesForSelfExtract=true \
-p:EnableCompressionInSingleFile=true \
-p:DebugType=none \
-p:DebugSymbols=false
if [ $? -ne 0 ]; then
echo "Launcher AOT publish failed"
exit 1
fi
echo "Launcher published to: ./publish/launcher-linux-x64"
ls -lh ./publish/launcher-linux-x64/
- name: Publish Main App
run: |
@@ -731,18 +751,29 @@ jobs:
-p:FileVersion=${{ needs.prepare.outputs.assembly_version }}
-p:InformationalVersion=${{ needs.prepare.outputs.informational_version }}
- name: Publish Launcher
- name: Publish Launcher (AOT)
run: |
echo "Publishing Launcher with AOT for macOS ${{ matrix.arch }}..."
dotnet publish LanMountainDesktop.Launcher/LanMountainDesktop.Launcher.csproj \
-c Release \
-o ./publish/launcher-macos-${{ matrix.arch }} \
--self-contained \
-r osx-${{ matrix.arch }} \
-p:PublishSingleFile=false \
-p:PublishTrimmed=false \
-p:PublishReadyToRun=false \
-p:PublishAot=true \
-p:PublishSingleFile=true \
-p:IncludeNativeLibrariesForSelfExtract=true \
-p:EnableCompressionInSingleFile=true \
-p:DebugType=none \
-p:DebugSymbols=false
if [ $? -ne 0 ]; then
echo "Launcher AOT publish failed"
exit 1
fi
echo "Launcher published to: ./publish/launcher-macos-${{ matrix.arch }}"
ls -lh ./publish/launcher-macos-${{ matrix.arch }}/
- name: Publish Main App
run: |
@@ -909,6 +940,8 @@ jobs:
- **LanMountainDesktop-Setup-${{ needs.prepare.outputs.version }}-x64.exe** - 64-bit installer (includes .NET runtime)
- **LanMountainDesktop-Setup-${{ needs.prepare.outputs.version }}-x86.exe** - 32-bit installer (includes .NET runtime)
**Note:** The Launcher is now built with AOT (Ahead-of-Time) compilation as a single executable file for faster startup and smaller footprint.
Installation: Double-click the .exe file and follow the wizard.
### Incremental Update (Windows x64)

View File

@@ -1,8 +1,11 @@
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;
@@ -18,33 +21,367 @@ public partial class App : Application
if (ApplicationLifetime is IClassicDesktopStyleApplicationLifetime desktop)
{
var context = LauncherRuntimeContext.Current;
var appRoot = Commands.ResolveAppRoot(context);
var deploymentLocator = new DeploymentLocator(appRoot);
// TODO: 从配置读取 GitHub 仓库信息
var updateCheckService = new UpdateCheckService("ClassIsland", "LanMountainDesktop");
var coordinator = new LauncherFlowCoordinator(
context,
deploymentLocator,
new OobeStateService(appRoot),
new UpdateEngineService(deploymentLocator),
updateCheckService,
new PluginInstallerService());
_ = RunCoordinatorAsync(desktop, coordinator);
// 调试模式:显示开发调试窗口
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
{
// 正常启动流程
var appRoot = Commands.ResolveAppRoot(context);
var deploymentLocator = new DeploymentLocator(appRoot);
// TODO: 从配置读取 GitHub 仓库信息
var updateCheckService = new UpdateCheckService("ClassIsland", "LanMountainDesktop");
var coordinator = new LauncherFlowCoordinator(
context,
deploymentLocator,
new OobeStateService(appRoot),
new UpdateEngineService(deploymentLocator),
updateCheckService,
new PluginInstallerService());
// 先显示 Splash 窗口,确保应用程序不会立即退出
var splashWindow = new SplashWindow();
splashWindow.Show();
// 启动协调器流程
_ = RunCoordinatorWithSplashAsync(desktop, coordinator, splashWindow);
}
}
base.OnFrameworkInitializationCompleted();
}
private static async Task RunCoordinatorAsync(
IClassicDesktopStyleApplicationLifetime desktop,
LauncherFlowCoordinator coordinator)
/// <summary>
/// 处理界面预览命令
/// </summary>
private bool HandlePreviewCommand(CommandContext context, IClassicDesktopStyleApplicationLifetime desktop)
{
var result = await coordinator.RunAsync().ConfigureAwait(false);
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,
LauncherFlowCoordinator coordinator,
SplashWindow splashWindow)
{
LauncherResult result;
ErrorWindow? errorWindow = null;
try
{
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. 清理旧版本
if (success)
{
await Dispatcher.UIThread.InvokeAsync(() => window.Report("cleanup", "正在清理...", 90));
deploymentLocator.CleanupDestroyedDeployments();
}
}
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);
}
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 11 KiB

View File

@@ -10,16 +10,30 @@ internal sealed class CommandContext
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");
private CommandContext(string command, string subCommand, Dictionary<string, string> options)
/// <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)
@@ -32,7 +46,7 @@ internal sealed class CommandContext
? args[1]
: string.Empty;
return new CommandContext(command, subCommand, options);
return new CommandContext(command, subCommand, options, args);
}
public string? GetOption(string key)

View File

@@ -0,0 +1,62 @@
<!-- 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>win-x64</RuntimeIdentifier>
</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>
<!-- FluentAvaloniaUI 需要启用反射序列化AOT 兼容模式) -->
<JsonSerializerIsReflectionEnabledByDefault>true</JsonSerializerIsReflectionEnabledByDefault>
</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

@@ -1,4 +1,8 @@
<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>
@@ -7,6 +11,8 @@
<Version>1.0.0</Version>
<PackageVersion>$(Version)</PackageVersion>
<AvaloniaUseCompiledBindingsByDefault>true</AvaloniaUseCompiledBindingsByDefault>
<!-- 应用程序图标 -->
<ApplicationIcon>Assets\logo.ico</ApplicationIcon>
</PropertyGroup>
<ItemGroup>
@@ -23,9 +29,12 @@
<PackageReference Include="Tmds.DBus.Protocol" Version="0.92.0" />
</ItemGroup>
<!-- Embed public-key.pem and copy to .launcher/update/ in output directory -->
<!-- 资源文件 -->
<ItemGroup>
<!-- 公钥文件 -->
<None Include="Assets\public-key.pem" CopyToOutputDirectory="PreserveNewest" />
<!-- Avalonia 资源文件 -->
<AvaloniaResource Include="Assets\logo.ico" />
</ItemGroup>
<Target Name="CopyPublicKeyToLauncherDir" AfterTargets="Build">

View File

@@ -1,14 +1,7 @@
using System.Diagnostics;
using Avalonia;
using LanMountainDesktop.Launcher.Models;
using LanMountainDesktop.Launcher.Services;
#if WINDOWS
using Windows.Win32;
using Windows.Win32.Foundation;
using Windows.Win32.UI.WindowsAndMessaging;
#endif
namespace LanMountainDesktop.Launcher;
internal static class Program
@@ -25,6 +18,14 @@ internal static class Program
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))
{
@@ -37,150 +38,6 @@ internal static class Program
return Environment.ExitCode;
}
private static int LaunchMainApplication(string[] args)
{
// 获取可执行文件名
string executableName = OperatingSystem.IsWindows()
? "LanMountainDesktop.exe"
: "LanMountainDesktop";
// 获取安装根目录
var rootDir = Path.GetFullPath(Path.GetDirectoryName(Environment.ProcessPath) ?? "");
// 查找最佳版本
var installation = FindBestVersion(rootDir, executableName);
if (installation == null)
{
ShowError("找不到有效的 LanMountainDesktop 版本,可能是安装已损坏。\n请访问 https://github.com/ClassIsland/LanMountainDesktop 重新下载并安装。");
return 1;
}
var exePath = Path.Combine(installation, executableName);
// Linux/macOS: 自动添加可执行权限
if (OperatingSystem.IsLinux() || OperatingSystem.IsMacOS())
{
try
{
var chmod = Process.Start(new ProcessStartInfo
{
FileName = "chmod",
Arguments = $"+x \"{exePath}\"",
CreateNoWindow = true
});
chmod?.WaitForExit();
}
catch (Exception ex)
{
Console.Error.WriteLine($"无法设置可执行权限: {ex.Message}");
}
}
// 清理待删除的旧版本
CleanupDestroyedVersions(rootDir);
// 启动主程序
var startInfo = new ProcessStartInfo
{
FileName = exePath,
WorkingDirectory = rootDir,
UseShellExecute = false
};
foreach (var arg in args)
{
startInfo.ArgumentList.Add(arg);
}
// 传递包根目录环境变量
startInfo.EnvironmentVariables["LanMountainDesktop_PackageRoot"] = rootDir;
try
{
Process.Start(startInfo);
return 0;
}
catch (Exception ex)
{
ShowError($"启动主程序失败: {ex.Message}");
return 1;
}
}
private static string? FindBestVersion(string rootDir, string executableName)
{
return Directory.GetDirectories(rootDir)
.Where(x =>
{
var dirName = Path.GetFileName(x);
return dirName.StartsWith("app-") &&
!File.Exists(Path.Combine(x, ".destroy")) &&
!File.Exists(Path.Combine(x, ".partial")) &&
File.Exists(Path.Combine(x, executableName));
})
.OrderBy(x => File.Exists(Path.Combine(x, ".current")) ? 0 : 1) // .current 优先
.ThenByDescending(x => ParseVersion(Path.GetFileName(x))) // 版本号降序
.FirstOrDefault();
}
private static Version ParseVersion(string dirName)
{
// 从 "app-1.0.0" 格式解析版本号
var parts = dirName.Split('-');
if (parts.Length >= 2 && Version.TryParse(parts[1], out var version))
{
return version;
}
return new Version(0, 0, 0);
}
private static void CleanupDestroyedVersions(string rootDir)
{
try
{
var destroyedDirs = Directory.GetDirectories(rootDir)
.Where(x => File.Exists(Path.Combine(x, ".destroy")));
foreach (var dir in destroyedDirs)
{
try
{
Directory.Delete(dir, recursive: true);
}
catch
{
// 忽略删除失败(可能文件被占用),下次启动再试
}
}
}
catch
{
// 忽略清理失败
}
}
private static void ShowError(string message)
{
#if WINDOWS
try
{
PInvoke.MessageBox(
HWND.Null,
message,
"LanMountainDesktop Launcher",
MESSAGEBOX_STYLE.MB_ICONERROR | MESSAGEBOX_STYLE.MB_OK
);
}
catch
{
Console.Error.WriteLine(message);
}
#else
Console.Error.WriteLine(message);
#endif
}
private static AppBuilder BuildAvaloniaApp()
{
return AppBuilder.Configure<App>()

View File

@@ -165,10 +165,27 @@ internal static class Commands
}
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");
return File.Exists(parentHost) ? parent : baseDir;
if (File.Exists(parentHost))
{
return parent;
}
// 默认返回 baseDir
return baseDir;
}
}

View File

@@ -29,4 +29,12 @@ internal sealed class DeferredSplashStageReporter : ISplashStageReporter
_pending.Add((stage, message));
}
}
public void ReportStage(string stage, int progress)
{
if (_inner is not null)
{
_inner.ReportStage(stage, progress);
}
}
}

View File

@@ -1,4 +1,6 @@
using System.Globalization;
using System.Text.Json;
using LanMountainDesktop.Shared.Contracts.Launcher;
namespace LanMountainDesktop.Launcher.Services;
@@ -56,6 +58,38 @@ internal sealed class DeploymentLocator
}
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";
@@ -85,9 +119,17 @@ internal sealed class DeploymentLocator
return inParent;
}
// 4. 开发模式:如果启用了开发模式,优先扫描开发路径
// 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))
{
@@ -242,4 +284,39 @@ internal sealed class DeploymentLocator
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<AppVersionInfo>(json);
if (info is not null)
{
return info;
}
}
catch
{
// 忽略读取失败,回退到默认值
}
}
}
// 回退:从目录名解析版本,使用默认开发代号
return new AppVersionInfo
{
Version = GetCurrentVersion(),
Codename = "Administrate" // 默认开发代号
};
}
}

View File

@@ -0,0 +1,612 @@
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;
public FlexibleHostLocator(string appRoot, HostDiscoveryOptions? options = null)
{
_appRoot = appRoot;
_options = options ?? new HostDiscoveryOptions();
}
/// <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. 搜索部署目录app-*- 生产环境标准路径
var deploymentPath = SearchDeploymentDirectories(searchContext);
if (!string.IsNullOrWhiteSpace(deploymentPath))
{
return deploymentPath;
}
// 3. 检查 Launcher 同级目录(便携模式)
var portablePath = SearchPortableLocation(searchContext);
if (!string.IsNullOrWhiteSpace(portablePath))
{
return portablePath;
}
// ========== 第二阶段:灵活查找(标准路径找不到时)==========
// 4. 检查配置文件中的路径 - 用户自定义配置
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;
}
// 6. 开发模式:检查保存的自定义路径
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;
}
}
// 7. 搜索标准开发路径
var devPath = SearchDevelopmentPaths(searchContext);
if (!string.IsNullOrWhiteSpace(devPath))
{
return devPath;
}
// 8. 搜索额外的配置路径
var additionalPath = SearchAdditionalPaths(searchContext);
if (!string.IsNullOrWhiteSpace(additionalPath))
{
return additionalPath;
}
// 9. 递归搜索(如果启用)
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<HostDiscoveryConfig>(json);
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>
private 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

@@ -3,4 +3,9 @@ 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

@@ -1,3 +1,4 @@
using System.Buffers;
using System.IO.Pipes;
using System.Text.Json;
using LanMountainDesktop.Shared.Contracts.Launcher;
@@ -6,81 +7,164 @@ namespace LanMountainDesktop.Launcher.Services.Ipc;
/// <summary>
/// Launcher IPC 服务端 - 接收主程序的启动进度报告
/// 采用持久连接 + 长度前缀协议,支持客户端在同一连接上多次发送消息。
/// 跨平台实现Windows 使用命名管道Linux/macOS 使用 Unix 域套接字
/// </summary>
public class LauncherIpcServer : IDisposable
{
private readonly CancellationTokenSource _cts = new();
private NamedPipeServerStream? _pipeServer;
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(async () =>
_listenTask = Task.Run(ListenLoopAsync, _cts.Token);
}
private async Task ListenLoopAsync()
{
while (!_cts.Token.IsCancellationRequested)
{
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
{
_pipeServer = new NamedPipeServerStream(
LauncherIpcConstants.PipeName,
PipeDirection.In,
1,
PipeTransmissionMode.Message);
await _pipeServer.WaitForConnectionAsync(_cts.Token);
using var reader = new StreamReader(_pipeServer);
var json = await reader.ReadToEndAsync(_cts.Token);
if (!string.IsNullOrEmpty(json))
{
try
{
var message = JsonSerializer.Deserialize<StartupProgressMessage>(json);
if (message != null)
{
_onProgress(message);
}
}
catch (JsonException)
{
// 忽略解析错误
}
}
try
{
_pipeServer.Disconnect();
}
catch { }
await Task.Delay(200, _cts.Token);
}
catch (OperationCanceledException)
{
break;
}
catch (IOException)
}
finally
{
try
{
// 管道断开,继续监听
continue;
pipe?.Dispose();
}
catch (Exception ex)
catch { }
if (ReferenceEquals(_currentPipe, pipe))
{
Console.Error.WriteLine($"IPC error: {ex.Message}");
await Task.Delay(100, _cts.Token);
_currentPipe = null;
}
}
}, _cts.Token);
}
}
/// <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<StartupProgressMessage>(json);
if (message is not null)
{
_onProgress(message);
}
}
catch (JsonException)
{
// 忽略解析错误,继续读取下一条消息
}
finally
{
ArrayPool<byte>.Shared.Return(payloadBuffer);
}
}
}
finally
{
ArrayPool<byte>.Shared.Return(lengthBuffer);
}
}
/// <summary>
/// 停止 IPC 服务端
/// </summary>
@@ -89,17 +173,16 @@ public class LauncherIpcServer : IDisposable
_cts.Cancel();
try
{
_pipeServer?.Disconnect();
_currentPipe?.Dispose();
}
catch { }
}
public void Dispose()
{
Stop();
_pipeServer?.Dispose();
_cts.Dispose();
try
{
_listenTask?.Wait(TimeSpan.FromSeconds(2));

View File

@@ -34,15 +34,15 @@ internal sealed class LauncherFlowCoordinator
_oobeSteps = [new WelcomeOobeStep(_oobeStateService)];
}
public async Task<LauncherResult> RunAsync()
public async Task<LauncherResult> RunAsync(SplashWindow? existingSplashWindow = null)
{
try
{
// 清理待删除的旧版本
_deploymentLocator.CleanupDestroyedDeployments();
// 显示 Splash 窗口
var splashWindow = await Dispatcher.UIThread.InvokeAsync(() =>
// 使用传入的 Splash 窗口或创建新的
var splashWindow = existingSplashWindow ?? await Dispatcher.UIThread.InvokeAsync(() =>
{
var window = new SplashWindow();
window.Show();
@@ -51,12 +51,29 @@ internal sealed class LauncherFlowCoordinator
var reporter = (ISplashStageReporter)splashWindow;
// 跟踪主程序是否已就绪,就绪后自动关闭 Splash 窗口
var hostReadyTcs = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously);
// 启动 IPC 服务端监听主程序进度
using var ipcServer = new LauncherIpcServer(msg =>
{
Dispatcher.UIThread.Post(() =>
{
reporter.Report(msg.Stage.ToString().ToLower(), msg.Message ?? "");
try
{
reporter.Report(msg.Stage.ToString().ToLower(), msg.Message ?? "");
// 主程序报告就绪后,关闭 Splash 窗口
if (msg.Stage == StartupStage.Ready && splashWindow.IsVisible && splashWindow.IsLoaded)
{
splashWindow.Close();
hostReadyTcs.TrySetResult();
}
}
catch (Exception ex)
{
Console.Error.WriteLine($"[LauncherFlowCoordinator] Error in IPC callback: {ex.Message}");
}
});
});
ipcServer.Start();
@@ -94,14 +111,53 @@ internal sealed class LauncherFlowCoordinator
// 启动主程序
reporter.Report("launch", "正在启动...");
var hostResult = await LaunchHostWithIpcAsync();
var (hostResult, hostProcess) = await LaunchHostWithIpcAsync(splashWindow);
if (!hostResult.Success)
{
return hostResult;
}
// 等待主程序就绪或超时
await Task.Delay(TimeSpan.FromSeconds(30));
// 等待主程序进程退出。Launcher 作为后台守护进程保持运行,
// 维持 IPC 管道服务端供主程序报告启动进度。
if (hostProcess is not null)
{
// 等待主程序就绪或进程退出(取先发生者)
// 如果主程序在 60 秒内未报告 Ready也关闭 Splash 窗口作为超时保护
var readyOrTimeout = Task.WhenAny(
hostReadyTcs.Task,
Task.Delay(TimeSpan.FromSeconds(60)));
var processExitTask = hostProcess.WaitForExitAsync();
// 先等待就绪/超时,然后等待进程退出
await readyOrTimeout;
// 如果 Splash 窗口仍然打开(超时情况),关闭它
if (splashWindow.IsVisible)
{
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}");
}
});
}
await processExitTask;
}
else
{
// 如果无法获取进程引用,退回到有限等待
await Task.Delay(TimeSpan.FromSeconds(30));
}
return new LauncherResult
{
@@ -113,7 +169,22 @@ internal sealed class LauncherFlowCoordinator
}
finally
{
await Dispatcher.UIThread.InvokeAsync(() => splashWindow.Close());
// 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)
@@ -124,12 +195,12 @@ internal sealed class LauncherFlowCoordinator
Stage = "launch",
Code = "exception",
Message = ex.Message,
ErrorMessage = ex.Message
ErrorMessage = ex.ToString()
};
}
}
private async Task<LauncherResult> LaunchHostWithIpcAsync(string? customHostPath = null)
private async Task<(LauncherResult Result, Process? Process)> LaunchHostWithIpcAsync(SplashWindow? splashWindow = null, string? customHostPath = null)
{
// 优先使用自定义路径(调试模式选择的路径)
var hostPath = customHostPath ?? _deploymentLocator.ResolveHostExecutablePath();
@@ -145,19 +216,19 @@ internal sealed class LauncherFlowCoordinator
// 用户选择重试,如果有选择路径则使用,否则重新尝试
if (!string.IsNullOrWhiteSpace(selectedPath))
{
return await LaunchHostWithIpcAsync(selectedPath);
return await LaunchHostWithIpcAsync(splashWindow, selectedPath);
}
return await LaunchHostWithIpcAsync();
return await LaunchHostWithIpcAsync(splashWindow);
}
// 用户选择退出
return new LauncherResult
return (new LauncherResult
{
Success = false,
Stage = "launchHost",
Code = "host_not_found",
Message = "LanMountainDesktop host executable not found."
};
}, null);
}
if (OperatingSystem.IsLinux() || OperatingSystem.IsMacOS())
@@ -168,24 +239,40 @@ internal sealed class LauncherFlowCoordinator
var processStartInfo = new ProcessStartInfo
{
FileName = hostPath,
UseShellExecute = true,
UseShellExecute = false,
WorkingDirectory = Path.GetDirectoryName(hostPath) ?? _deploymentLocator.GetAppRoot()
};
// 转发命令行参数给主程序(排除 Launcher 自己的命令和选项)
foreach (var arg in _context.RawArgs)
{
// 跳过 Launcher 自己的命令和选项,只传递用户原始参数
if (arg == _context.Command || arg == _context.SubCommand || arg.StartsWith("--"))
{
continue;
}
processStartInfo.ArgumentList.Add(arg);
}
// 传递环境变量供 IPC 使用
processStartInfo.EnvironmentVariables[LauncherIpcConstants.LauncherPidEnvVar] =
Environment.ProcessId.ToString();
processStartInfo.EnvironmentVariables[LauncherIpcConstants.PackageRootEnvVar] =
_deploymentLocator.GetAppRoot();
// 传递版本信息
var versionInfo = _deploymentLocator.GetVersionInfo();
processStartInfo.EnvironmentVariables[LauncherIpcConstants.VersionEnvVar] = versionInfo.Version;
processStartInfo.EnvironmentVariables[LauncherIpcConstants.CodenameEnvVar] = versionInfo.Codename;
Process.Start(processStartInfo);
return new LauncherResult
var hostProcess = Process.Start(processStartInfo);
return (new LauncherResult
{
Success = true,
Stage = "launchHost",
Code = "ok",
Message = "Host launched."
};
}, hostProcess);
}
/// <summary>
@@ -193,19 +280,65 @@ internal sealed class LauncherFlowCoordinator
/// </summary>
private async Task<(ErrorWindowResult Result, string? CustomPath)> ShowHostNotFoundErrorAsync()
{
return await Dispatcher.UIThread.InvokeAsync(async () =>
ErrorWindow? errorWindow = null;
// 在 UI 线程创建并显示错误窗口
await Dispatcher.UIThread.InvokeAsync(() =>
{
var errorWindow = new ErrorWindow();
errorWindow.SetErrorMessage("找不到阑山桌面应用程序。");
errorWindow.Show();
var result = await errorWindow.WaitForChoiceAsync();
var customPath = errorWindow.GetCustomHostPath();
await Dispatcher.UIThread.InvokeAsync(() => errorWindow.Close());
return (result, customPath);
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);
}
private static void EnsureExecutable(string path)
@@ -237,22 +370,72 @@ internal sealed class LauncherFlowCoordinator
public async Task RunAsync(CancellationToken cancellationToken)
{
var window = await Dispatcher.UIThread.InvokeAsync(() =>
{
var oobeWindow = new OobeWindow();
oobeWindow.Show();
return oobeWindow;
});
OobeWindow? window = null;
try
{
using var _ = cancellationToken.Register(() => window.Close());
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
{
await Dispatcher.UIThread.InvokeAsync(() => window.Close());
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

@@ -6,7 +6,12 @@ internal sealed class OobeStateService
public OobeStateService(string appRoot)
{
var stateDir = Path.Combine(appRoot, ".launcher", "state");
// 将 OOBE 状态文件存储在用户可写的 LocalApplicationData 目录中,
// 而不是安装目录Program Files 下普通用户没有写入权限)。
var appDataDir = Path.Combine(
Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData),
"LanMountainDesktop");
var stateDir = Path.Combine(appDataDir, ".launcher", "state");
Directory.CreateDirectory(stateDir);
_markerPath = Path.Combine(stateDir, "first_run_completed");
}

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

@@ -14,7 +14,8 @@
CanResize="False"
WindowStartupLocation="CenterOwner"
Background="{DynamicResource SolidBackgroundFillColorBaseBrush}"
TransparencyLevelHint="None">
TransparencyLevelHint="None"
Icon="/Assets/logo.ico">
<Design.DataContext>
<views:ErrorDebugWindow />
</Design.DataContext>

View File

@@ -11,6 +11,7 @@ namespace LanMountainDesktop.Launcher.Views;
public partial class ErrorDebugWindow : Window
{
private string? _selectedHostPath;
private bool _isInitialized = false;
/// <summary>
/// 是否启用了开发模式
@@ -25,22 +26,36 @@ public partial class ErrorDebugWindow : Window
public ErrorDebugWindow()
{
AvaloniaXamlLoader.Load(this);
InitializeComponents();
// 延迟到窗口加载完成后再初始化组件
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 = devModeEnabled;
devModeToggle.IsChecked = IsDevModeEnabled;
}
UpdatePathDisplay(initialPath);
UpdatePathDisplay(_selectedHostPath);
}
private void InitializeComponents()
@@ -52,7 +67,13 @@ public partial class ErrorDebugWindow : Window
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!");
}
// 浏览按钮
@@ -60,6 +81,11 @@ public partial class ErrorDebugWindow : Window
if (browseButton is not null)
{
browseButton.Click += OnBrowseClick;
Console.WriteLine("[ErrorDebugWindow] BrowseButton event bound");
}
else
{
Console.Error.WriteLine("[ErrorDebugWindow] Failed to find BrowseButton!");
}
// 确定按钮
@@ -67,6 +93,11 @@ public partial class ErrorDebugWindow : Window
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!");
}
// 取消按钮
@@ -78,9 +109,17 @@ public partial class ErrorDebugWindow : Window
// 取消时恢复原始状态
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>
@@ -110,6 +149,7 @@ public partial class ErrorDebugWindow : Window
if (result.Count > 0)
{
_selectedHostPath = result[0].Path.LocalPath;
Console.WriteLine($"[ErrorDebugWindow] Selected host path: {_selectedHostPath}");
UpdatePathDisplay(_selectedHostPath);
}
}
@@ -124,5 +164,9 @@ public partial class ErrorDebugWindow : Window
{
pathTextBlock.Text = string.IsNullOrEmpty(path) ? "未选择" : path;
}
else
{
Console.Error.WriteLine("[ErrorDebugWindow] Failed to find PathTextBlock!");
}
}
}

View File

@@ -3,82 +3,94 @@
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"
d:DesignWidth="520"
d:DesignHeight="280"
x:Class="LanMountainDesktop.Launcher.Views.ErrorWindow"
x:DataType="views:ErrorWindow"
Title="阑山桌面 - 启动失败"
Width="480"
Height="320"
Title="阑山桌面"
Width="520"
Height="280"
CanResize="False"
WindowStartupLocation="CenterScreen"
Background="{DynamicResource SolidBackgroundFillColorBaseBrush}"
TransparencyLevelHint="None">
TransparencyLevelHint="None"
Icon="/Assets/logo.ico">
<Design.DataContext>
<views:ErrorWindow />
</Design.DataContext>
<Grid Margin="40" RowDefinitions="Auto,*,Auto">
<!-- 错误图标和标题 -->
<StackPanel Grid.Row="0" HorizontalAlignment="Center">
<!-- 错误图标 - 可点击进入调试模式(隐藏功能,无提示) -->
<!-- Fluent Design 风格对话框布局 -->
<Grid RowDefinitions="*,Auto">
<!-- 主内容区域:左侧图标 + 右侧文字 -->
<Grid Grid.Row="0" Margin="24,24,24,16" ColumnDefinitions="Auto,*">
<!-- 左侧:错误图标(可点击进入调试模式) -->
<Border x:Name="ErrorIconBorder"
Width="64"
Height="64"
Grid.Column="0"
Width="48"
Height="48"
Margin="0,4,16,0"
Background="{DynamicResource SystemFillColorCriticalBackgroundBrush}"
CornerRadius="32"
HorizontalAlignment="Center">
<TextBlock x:Name="ErrorIconText"
Text="!"
FontSize="36"
FontWeight="Bold"
CornerRadius="24"
VerticalAlignment="Top">
<TextBlock Text="&#xEA39;"
FontSize="24"
FontFamily="{DynamicResource SymbolThemeFontFamily}"
Foreground="{DynamicResource SystemFillColorCriticalBrush}"
HorizontalAlignment="Center"
VerticalAlignment="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>
<TextBlock Text="启动失败"
FontSize="24"
FontWeight="Medium"
Foreground="{DynamicResource TextFillColorPrimaryBrush}"
HorizontalAlignment="Center"
Margin="0,20,0,0" />
</StackPanel>
<!-- 错误信息 -->
<StackPanel Grid.Row="1" VerticalAlignment="Center" Margin="0,20">
<TextBlock x:Name="ErrorMessageText"
Text="找不到阑山桌面应用程序。"
FontSize="14"
Foreground="{DynamicResource TextFillColorPrimaryBrush}"
TextWrapping="Wrap"
TextAlignment="Center"
LineHeight="22" />
<TextBlock Text="请确保应用程序已正确安装,或尝试重新安装。"
FontSize="13"
Foreground="{DynamicResource TextFillColorSecondaryBrush}"
TextWrapping="Wrap"
TextAlignment="Center"
Margin="0,12,0,0"
LineHeight="20" />
</StackPanel>
<!-- 按钮区域 -->
<StackPanel Grid.Row="2" Orientation="Horizontal" HorizontalAlignment="Center" Spacing="12">
<Button x:Name="RetryButton"
Content="重试"
Width="100"
Height="36"
FontSize="14"
Theme="{DynamicResource AccentButtonTheme}" />
<Button x:Name="ExitButton"
Content="退出"
Width="100"
Height="36"
FontSize="14" />
</StackPanel>
<!-- 底部:按钮区域 -->
<Border Grid.Row="1"
Background="{DynamicResource CardBackgroundFillColorDefaultBrush}"
Padding="24,16">
<StackPanel Orientation="Horizontal"
HorizontalAlignment="Right"
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>
</Border>
</Grid>
</Window>

View File

@@ -23,17 +23,44 @@ public partial class ErrorWindow : Window
// 先加载保存的状态
_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!");
}
// 按钮事件
@@ -43,12 +70,24 @@ public partial class ErrorWindow : Window
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!");
}
Console.WriteLine("[ErrorWindow] Components initialization completed");
}
/// <summary>
@@ -63,6 +102,19 @@ public partial class ErrorWindow : Window
}
}
/// <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>
@@ -114,13 +166,19 @@ public partial class ErrorWindow : Window
_devModeEnabled = debugWindow.IsDevModeEnabled;
_customHostPath = debugWindow.SelectedHostPath;
// 保存开发模式状态
// 保存开发模式状态和自定义路径
SaveDevModeStateInternal(_devModeEnabled);
SaveCustomHostPathInternal(_customHostPath);
// 如果启用了开发模式且没有选择路径,自动扫描
if (_devModeEnabled && string.IsNullOrEmpty(_customHostPath))
{
ScanDevPaths();
// 扫描到路径后也保存
if (!string.IsNullOrEmpty(_customHostPath))
{
SaveCustomHostPathInternal(_customHostPath);
}
}
};
@@ -203,6 +261,71 @@ public partial class ErrorWindow : Window
return Path.Combine(appData, "LanMountainDesktop", ".launcher", "devmode.config");
}
/// <summary>
/// 保存自定义主程序路径(内部方法)
/// </summary>
private static void SaveCustomHostPathInternal(string? path)
{
try
{
var hostPathFile = GetCustomHostPathFilePath();
var dir = Path.GetDirectoryName(hostPathFile);
if (!string.IsNullOrEmpty(dir) && !Directory.Exists(dir))
{
Directory.CreateDirectory(dir);
}
File.WriteAllText(hostPathFile, path ?? string.Empty);
}
catch (Exception ex)
{
Console.Error.WriteLine($"Failed to save custom host path: {ex.Message}");
}
}
/// <summary>
/// 加载自定义主程序路径(内部方法)
/// </summary>
private static string? LoadCustomHostPathInternal()
{
try
{
var hostPathFile = GetCustomHostPathFilePath();
if (File.Exists(hostPathFile))
{
var content = File.ReadAllText(hostPathFile).Trim();
// 验证路径是否仍然有效
if (!string.IsNullOrEmpty(content) && File.Exists(content))
{
return content;
}
// 路径已失效,清理配置文件
try
{
File.Delete(hostPathFile);
Console.WriteLine("Custom host path is no longer valid, cleared saved path.");
}
catch (Exception clearEx)
{
Console.Error.WriteLine($"Failed to clear invalid host path: {clearEx.Message}");
}
}
}
catch (Exception ex)
{
Console.Error.WriteLine($"Failed to load custom host path: {ex.Message}");
}
return null;
}
/// <summary>
/// 获取自定义主程序路径文件路径
/// </summary>
private static string GetCustomHostPathFilePath()
{
var appData = Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData);
return Path.Combine(appData, "LanMountainDesktop", ".launcher", "custom-host-path.config");
}
/// <summary>
/// 检查是否启用了开发模式(静态方法,启动时调用)
/// </summary>
@@ -211,6 +334,14 @@ public partial class ErrorWindow : Window
return LoadDevModeStateInternal();
}
/// <summary>
/// 获取保存的自定义主程序路径(静态方法,启动时调用)
/// </summary>
public static string? GetSavedCustomHostPath()
{
return LoadCustomHostPathInternal();
}
private void OnRetryClick(object? sender, RoutedEventArgs e)
{
_completionSource.TrySetResult(ErrorWindowResult.Retry);

View File

@@ -5,51 +5,72 @@
xmlns:views="clr-namespace:LanMountainDesktop.Launcher.Views"
xmlns:ui="using:FluentAvalonia.UI.Controls"
mc:Ignorable="d"
d:DesignWidth="420"
d:DesignHeight="260"
d:DesignWidth="600"
d:DesignHeight="500"
x:Class="LanMountainDesktop.Launcher.Views.OobeWindow"
x:DataType="views:OobeWindow"
Title="欢迎使用阑山桌面"
Width="420"
Height="260"
Width="600"
Height="500"
CanResize="False"
WindowStartupLocation="CenterScreen"
Background="{DynamicResource SolidBackgroundFillColorBaseBrush}"
TransparencyLevelHint="None">
TransparencyLevelHint="None"
Icon="/Assets/logo.ico">
<Design.DataContext>
<views:OobeWindow />
</Design.DataContext>
<Grid Margin="32" RowDefinitions="*,Auto">
<!-- 欢迎文本 -->
<StackPanel Grid.Row="0" VerticalAlignment="Center" HorizontalAlignment="Center">
<TextBlock Text="欢迎使用"
FontSize="18"
Foreground="{DynamicResource TextFillColorSecondaryBrush}"
HorizontalAlignment="Center" />
<TextBlock Text="阑山桌面"
FontSize="32"
FontWeight="Light"
Foreground="{DynamicResource TextFillColorPrimaryBrush}"
HorizontalAlignment="Center"
Margin="0,8,0,0" />
<TextBlock Text="您的智能桌面助手"
FontSize="14"
Foreground="{DynamicResource TextFillColorTertiaryBrush}"
HorizontalAlignment="Center"
Margin="0,16,0,0" />
</StackPanel>
<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="Right"
Width="80"
Height="36"
Content="开始使用"
FontSize="14"
Background="{DynamicResource AccentFillColorDefaultBrush}"
Foreground="{DynamicResource TextOnAccentFillColorPrimaryBrush}"
CornerRadius="4" />
<!-- 底部:圆形开始按钮 -->
<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

@@ -1,24 +1,123 @@
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首次使用体验窗口
/// 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}");
}
}
@@ -27,8 +126,72 @@ public partial class OobeWindow : Window
/// </summary>
public Task WaitForEnterAsync() => _completionSource.Task;
private void OnEnterClick(object? sender, RoutedEventArgs e)
/// <summary>
/// 进入按钮点击事件
/// </summary>
private async void OnEnterClick(object? sender, RoutedEventArgs e)
{
_completionSource.TrySetResult(true);
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

@@ -5,53 +5,83 @@
xmlns:views="clr-namespace:LanMountainDesktop.Launcher.Views"
xmlns:ui="using:FluentAvalonia.UI.Controls"
mc:Ignorable="d"
d:DesignWidth="400"
d:DesignHeight="200"
d:DesignWidth="480"
d:DesignHeight="320"
x:Class="LanMountainDesktop.Launcher.Views.SplashWindow"
x:DataType="views:SplashWindow"
Title="阑山桌面"
Width="400"
Height="200"
Title="LanMountain Desktop"
Width="480"
Height="320"
CanResize="False"
WindowStartupLocation="CenterScreen"
SystemDecorations="None"
Background="{DynamicResource SolidBackgroundFillColorBaseBrush}"
TransparencyLevelHint="None">
TransparencyLevelHint="None"
Icon="/Assets/logo.ico">
<Design.DataContext>
<views:SplashWindow />
</Design.DataContext>
<Grid RowDefinitions="*,Auto,Auto">
<!-- 应用名称 -->
<Grid>
<!-- 左上角:应用名称 -->
<TextBlock x:Name="AppNameText"
Text="阑山桌面"
FontSize="36"
FontWeight="Light"
VerticalAlignment="Center"
HorizontalAlignment="Center"
Grid.Row="0"
Text="LanMountain Desktop"
FontSize="24"
FontWeight="SemiBold"
VerticalAlignment="Top"
HorizontalAlignment="Left"
Margin="24,24,0,0"
Foreground="{DynamicResource TextFillColorPrimaryBrush}" />
<!-- 进度条 -->
<ProgressBar x:Name="ProgressIndicator"
Grid.Row="1"
Minimum="0"
Maximum="100"
Value="0"
Height="3"
Width="200"
Margin="0,20,0,0"
IsIndeterminate="True"
Foreground="{DynamicResource AccentFillColorDefaultBrush}"
Background="{DynamicResource ControlStrokeColorDefaultBrush}" />
<!-- 状态文本 -->
<TextBlock x:Name="StatusText"
Grid.Row="2"
FontSize="12"
Foreground="{DynamicResource TextFillColorSecondaryBrush}"
HorizontalAlignment="Center"
Margin="0,12,0,24"
Text="正在启动..." />
<!-- 底部区域:进度条和状态 -->
<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

@@ -1,4 +1,6 @@
using Avalonia.Controls;
using Avalonia.Input;
using Avalonia.Interactivity;
using Avalonia.Markup.Xaml;
using Avalonia.Threading;
using LanMountainDesktop.Launcher.Services;
@@ -10,9 +12,89 @@ namespace LanMountainDesktop.Launcher.Views;
/// </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>
@@ -22,8 +104,14 @@ public partial class SplashWindow : Window, ISplashStageReporter
{
Dispatcher.UIThread.Post(() =>
{
var statusText = this.GetControl<TextBlock>("StatusText");
var progressIndicator = this.GetControl<ProgressBar>("ProgressIndicator");
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;
@@ -49,8 +137,14 @@ public partial class SplashWindow : Window, ISplashStageReporter
{
Dispatcher.UIThread.Post(() =>
{
var statusText = this.GetControl<TextBlock>("StatusText");
var progressIndicator = this.GetControl<ProgressBar>("ProgressIndicator");
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))
{
@@ -69,11 +163,75 @@ public partial class SplashWindow : Window, ISplashStageReporter
{
Dispatcher.UIThread.Post(() =>
{
var statusText = this.GetControl<TextBlock>("StatusText");
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>

View File

@@ -0,0 +1,68 @@
<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="400"
d:DesignHeight="220"
x:Class="LanMountainDesktop.Launcher.Views.UpdateWindow"
x:DataType="views:UpdateWindow"
Title="阑山桌面 - 更新"
Width="400"
Height="220"
CanResize="False"
WindowStartupLocation="CenterScreen"
SystemDecorations="None"
Background="{DynamicResource SolidBackgroundFillColorBaseBrush}"
TransparencyLevelHint="None"
Icon="/Assets/logo.ico">
<Design.DataContext>
<views:UpdateWindow />
</Design.DataContext>
<Grid RowDefinitions="Auto,*,Auto,Auto">
<!-- 应用名称 -->
<TextBlock x:Name="TitleText"
Text="阑山桌面"
FontSize="36"
FontWeight="Light"
VerticalAlignment="Center"
HorizontalAlignment="Center"
Grid.Row="0"
Margin="0,30,0,0"
Foreground="{DynamicResource TextFillColorPrimaryBrush}" />
<!-- 状态文本 -->
<TextBlock x:Name="StatusText"
Grid.Row="1"
FontSize="13"
Foreground="{DynamicResource TextFillColorSecondaryBrush}"
HorizontalAlignment="Center"
VerticalAlignment="Center"
Margin="0,16,0,0"
Text="正在更新,请稍候..." />
<!-- 进度条 -->
<ProgressBar x:Name="ProgressIndicator"
Grid.Row="2"
Minimum="0"
Maximum="100"
Value="0"
Height="3"
Width="200"
Margin="0,16,0,0"
IsIndeterminate="True"
Foreground="{DynamicResource AccentFillColorDefaultBrush}"
Background="{DynamicResource ControlStrokeColorDefaultBrush}" />
<!-- 底部提示 -->
<TextBlock x:Name="DetailText"
Grid.Row="3"
FontSize="11"
Foreground="{DynamicResource TextFillColorTertiaryBrush}"
HorizontalAlignment="Center"
Margin="0,12,0,24"
Text="" />
</Grid>
</Window>

View File

@@ -0,0 +1,117 @@
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);
}
/// <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 detailText = this.FindControl<TextBlock>("DetailText");
if (statusText is null || progressIndicator is null || detailText is null)
{
Console.Error.WriteLine($"[UpdateWindow] Controls not found in Report: StatusText={statusText != null}, ProgressIndicator={progressIndicator != null}, DetailText={detailText != null}");
return;
}
statusText.Text = message;
if (progressPercent >= 0)
{
progressIndicator.IsIndeterminate = false;
progressIndicator.Value = progressPercent;
}
else
{
progressIndicator.IsIndeterminate = true;
}
// 根据阶段显示不同的底部提示
detailText.Text = stage.ToLowerInvariant() switch
{
"verify" => "正在验证更新完整性...",
"extract" => "正在解压更新包...",
"apply" => "正在应用更新文件...",
"plugins" => "正在升级插件...",
"cleanup" => "正在清理...",
"done" => "",
_ => ""
};
});
}
/// <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 detailText = this.FindControl<TextBlock>("DetailText");
var titleText = this.FindControl<TextBlock>("TitleText");
if (statusText is null || progressIndicator is null || detailText is null || titleText is null)
{
Console.Error.WriteLine($"[UpdateWindow] Controls not found in ReportComplete");
return;
}
progressIndicator.IsIndeterminate = false;
progressIndicator.Value = 100;
detailText.Text = "";
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>

View File

@@ -0,0 +1,22 @@
namespace LanMountainDesktop.Shared.Contracts.Launcher;
/// <summary>
/// 应用版本信息
/// </summary>
public record AppVersionInfo
{
/// <summary>
/// 版本号,如 "1.0.0"
/// </summary>
public string Version { get; init; } = "0.0.0";
/// <summary>
/// 开发代号,如 "Administrate"
/// </summary>
public string Codename { get; init; } = "Unknown";
/// <summary>
/// 完整版本字符串,如 "1.0.0 (Administrate)"
/// </summary>
public string FullVersionText => $"{Version} ({Codename})";
}

View File

@@ -81,4 +81,9 @@ public static class LauncherIpcConstants
/// 版本环境变量
/// </summary>
public const string VersionEnvVar = "LMD_VERSION";
/// <summary>
/// 开发代号环境变量
/// </summary>
public const string CodenameEnvVar = "LMD_CODENAME";
}

View File

@@ -154,6 +154,7 @@ public partial class App : Application
RegisterUiUnhandledExceptionGuard();
LinuxDesktopEntryInstaller.EnsureInstalled();
ReportStartupProgress(StartupStage.LoadingSettings, 20, "正在加载设置...");
DesktopBootstrap.InitializeApplication(this, InitializeDesktopShell);
if (!Design.IsDesignMode && OperatingSystem.IsWindows())
@@ -177,14 +178,7 @@ public partial class App : Application
if (connected)
{
AppLogger.Info("LauncherIpc", "Connected to Launcher IPC server.");
// 报告初始化进度
await _launcherIpcClient.ReportProgressAsync(new StartupProgressMessage
{
Stage = StartupStage.Initializing,
ProgressPercent = 10,
Message = "正在初始化..."
});
ReportStartupProgress(StartupStage.Initializing, 10, "正在初始化...");
}
}
catch (Exception ex)
@@ -193,6 +187,32 @@ public partial class App : Application
}
}
/// <summary>
/// 向 Launcher 报告启动进度fire-and-forget不阻塞主流程
/// </summary>
private void ReportStartupProgress(StartupStage stage, int percent, string message)
{
if (_launcherIpcClient is null)
return;
_ = Task.Run(async () =>
{
try
{
await _launcherIpcClient.ReportProgressAsync(new StartupProgressMessage
{
Stage = stage,
ProgressPercent = percent,
Message = message
});
}
catch (Exception ex)
{
AppLogger.Warn("LauncherIpc", $"Failed to report progress: {ex.Message}");
}
});
}
private void ApplyDesignTimeTheme()
{
RequestedThemeVariant = ThemeVariant.Light;
@@ -218,6 +238,7 @@ public partial class App : Application
// More info: https://docs.avaloniaui.net/docs/guides/development-guides/data-validation#manage-validationplugins
DisableAvaloniaDataAnnotationValidation();
desktop.ShutdownMode = Avalonia.Controls.ShutdownMode.OnExplicitShutdown;
ReportStartupProgress(StartupStage.InitializingUI, 60, "正在初始化界面...");
CreateAndAssignMainWindow(desktop, "FrameworkInitialization");
},
() =>
@@ -358,6 +379,7 @@ public partial class App : Application
private void InitializePluginRuntime()
{
ReportStartupProgress(StartupStage.LoadingPlugins, 30, "正在加载插件...");
try
{
_pluginRuntimeService?.Dispose();
@@ -905,6 +927,7 @@ public partial class App : Application
AppLogger.Info("App", $"Main window created. Reason='{reason}'. LogFile={AppLogger.LogFilePath}");
LogBrowserStartupDiagnostics();
SetDesktopShellState(DesktopShellState.ForegroundDesktop, $"MainWindowCreated:{reason}");
ReportStartupProgress(StartupStage.Ready, 100, "就绪");
return mainWindow;
}

View File

@@ -81,4 +81,24 @@
</ItemGroup>
<!-- Launcher 构建目标已移除 - Launcher 现在是独立应用,由 CI/CD 单独构建 -->
<!-- 生成版本信息文件 -->
<Target Name="GenerateVersionFile" AfterTargets="Build">
<PropertyGroup>
<VersionFilePath>$(OutDir)version.json</VersionFilePath>
<AppVersion>$(Version)</AppVersion>
<AppCodename>Administrate</AppCodename>
</PropertyGroup>
<Exec Command="powershell -ExecutionPolicy Bypass -File $(MSBuildProjectDirectory)\..\scripts\Generate-VersionFile.ps1 -OutputPath '$(VersionFilePath)' -Version '$(AppVersion)' -Codename '$(AppCodename)'" />
</Target>
<!-- 发布时也生成版本信息文件 -->
<Target Name="GenerateVersionFilePublish" AfterTargets="Publish">
<PropertyGroup>
<VersionFilePath>$(PublishDir)version.json</VersionFilePath>
<AppVersion>$(Version)</AppVersion>
<AppCodename>Administrate</AppCodename>
</PropertyGroup>
<Exec Command="powershell -ExecutionPolicy Bypass -File $(MSBuildProjectDirectory)\..\scripts\Generate-VersionFile.ps1 -OutputPath '$(VersionFilePath)' -Version '$(AppVersion)' -Codename '$(AppCodename)'" />
</Target>
</Project>

View File

@@ -462,6 +462,12 @@
"settings.update.status_asset_missing": "A new release is available, but no compatible installer was found.",
"settings.update.status_available_format": "New version {0} is available. Click Download & Install.",
"settings.update.status_downloading": "Downloading installer...",
"settings.update.status_downloading_delta": "Downloading incremental update...",
"settings.update.status_delta_applying": "Applying incremental update. The app will close for update.",
"settings.update.status_delta_launch_failed": "Failed to launch updater for incremental update.",
"settings.update.type_label": "Update Type",
"settings.update.type_delta": "Incremental Update",
"settings.update.type_full": "Full Installer",
"settings.update.status_download_failed_format": "Download failed: {0}",
"settings.update.status_launching_installer": "Download complete. Launching installer...",
"settings.update.status_installer_missing": "Installer file was not found after download.",

View File

@@ -457,6 +457,12 @@
"settings.update.status_asset_missing": "发现新版本,但未找到兼容的安装包。",
"settings.update.status_available_format": "发现新版本 {0},点击“下载并安装”继续。",
"settings.update.status_downloading": "正在下载安装包...",
"settings.update.status_downloading_delta": "正在下载增量更新包...",
"settings.update.status_delta_applying": "正在应用增量更新,应用将关闭进行更新。",
"settings.update.status_delta_launch_failed": "启动增量更新程序失败。",
"settings.update.type_label": "更新类型",
"settings.update.type_delta": "增量更新",
"settings.update.type_full": "完整安装包",
"settings.update.status_download_failed_format": "下载失败:{0}",
"settings.update.status_launching_installer": "下载完成,正在启动安装程序...",
"settings.update.status_installer_missing": "下载后未找到安装包文件。",

View File

@@ -1,3 +1,5 @@
using System.Buffers;
using System.Diagnostics;
using System.IO.Pipes;
using System.Text.Json;
using LanMountainDesktop.Shared.Contracts.Launcher;
@@ -6,12 +8,20 @@ namespace LanMountainDesktop.Services.Launcher;
/// <summary>
/// Launcher IPC 客户端 - 向 Launcher 报告启动进度
/// 采用持久连接 + 长度前缀协议,在同一连接上可多次发送消息。
/// 跨平台实现Windows 使用命名管道Linux/macOS 使用 Unix 域套接字
/// </summary>
public class LauncherIpcClient : IDisposable
{
private NamedPipeClientStream? _pipeClient;
private bool _isConnected;
private readonly object _writeLock = new();
/// <summary>
/// 协议:每条消息以 4 字节小端 int32 长度前缀开头,后跟 UTF-8 JSON 正文。
/// </summary>
private const int LengthPrefixSize = 4;
/// <summary>
/// 连接到 Launcher 的 IPC 服务端
/// </summary>
@@ -23,7 +33,7 @@ public class LauncherIpcClient : IDisposable
".",
LauncherIpcConstants.PipeName,
PipeDirection.Out);
await _pipeClient.ConnectAsync(5000, cancellationToken);
_isConnected = true;
return true;
@@ -39,21 +49,34 @@ public class LauncherIpcClient : IDisposable
return false;
}
}
/// <summary>
/// 报告启动进度
/// 报告启动进度(在同一连接上可多次调用)
/// </summary>
public async Task ReportProgressAsync(StartupProgressMessage message)
{
if (!_isConnected || _pipeClient?.IsConnected != true)
return;
try
{
var json = JsonSerializer.Serialize(message);
using var writer = new StreamWriter(_pipeClient, leaveOpen: true);
await writer.WriteAsync(json);
await writer.FlushAsync();
var payload = System.Text.Encoding.UTF8.GetBytes(json);
// 长度前缀协议:[4字节长度][消息正文]
var lengthPrefix = BitConverter.GetBytes(payload.Length);
Debug.Assert(lengthPrefix.Length == LengthPrefixSize);
// 加锁保证单条消息的长度前缀和正文原子写入
lock (_writeLock)
{
_pipeClient.Write(lengthPrefix, 0, LengthPrefixSize);
_pipeClient.Write(payload, 0, payload.Length);
_pipeClient.Flush();
}
// 将同步写入包装为已完成的 Task
await Task.CompletedTask;
}
catch (IOException)
{
@@ -63,9 +86,10 @@ public class LauncherIpcClient : IDisposable
catch (Exception ex)
{
AppLogger.Warn("LauncherIpc", $"Failed to report progress: {ex.Message}");
_isConnected = false;
}
}
/// <summary>
/// 检查是否从 Launcher 启动
/// </summary>
@@ -74,9 +98,10 @@ public class LauncherIpcClient : IDisposable
return !string.IsNullOrEmpty(
Environment.GetEnvironmentVariable(LauncherIpcConstants.LauncherPidEnvVar));
}
public void Dispose()
{
_isConnected = false;
_pipeClient?.Dispose();
}
}

View File

@@ -1225,10 +1225,18 @@ internal sealed class PluginCatalogSettingsService : IPluginCatalogSettingsServi
internal sealed class ApplicationInfoService : IApplicationInfoService
{
private const string Codename = "Administrate";
private const string DefaultCodename = "Administrate";
public string GetAppVersionText()
{
// 优先从环境变量读取Launcher 传递)
var envVersion = Environment.GetEnvironmentVariable(LanMountainDesktop.Shared.Contracts.Launcher.LauncherIpcConstants.VersionEnvVar);
if (!string.IsNullOrWhiteSpace(envVersion))
{
return envVersion;
}
// 回退:从程序集读取
var assembly = typeof(App).Assembly;
var informationalVersion = assembly
.GetCustomAttribute<AssemblyInformationalVersionAttribute>()?
@@ -1268,7 +1276,15 @@ internal sealed class ApplicationInfoService : IApplicationInfoService
public string GetAppCodenameText()
{
return Codename;
// 优先从环境变量读取Launcher 传递)
var envCodename = Environment.GetEnvironmentVariable(LanMountainDesktop.Shared.Contracts.Launcher.LauncherIpcConstants.CodenameEnvVar);
if (!string.IsNullOrWhiteSpace(envCodename))
{
return envCodename;
}
// 回退:使用默认开发代号
return DefaultCodename;
}
public AppRenderBackendInfo GetRenderBackendInfo()

View File

@@ -489,13 +489,17 @@ public sealed class UpdateWorkflowService
return false;
}
// For delta updates, the files are already in .launcher/update/incoming/.
// Just exit the app - the Launcher will detect and apply the update on next startup.
// For delta updates, launch the Launcher with apply-update command so it can
// apply the update immediately with a progress UI, matching the full installer experience.
if (IsPendingDeltaUpdate())
{
AppLogger.Info("UpdateWorkflow", "Delta update pending in incoming directory. Exiting to let Launcher apply on next startup.");
ClearPendingUpdate();
return true;
AppLogger.Info("UpdateWorkflow", "Delta update pending. Launching Launcher to apply update with progress UI.");
var launchResult = LaunchLauncherForApplyUpdate();
if (launchResult)
{
ClearPendingUpdate();
}
return launchResult;
}
var result = LaunchPendingInstaller(silent: true, exitApplicationAfterLaunch: false);
@@ -507,6 +511,53 @@ public sealed class UpdateWorkflowService
return result.Success;
}
/// <summary>
/// Launches the Launcher process with the apply-update command to apply a pending delta update
/// with a progress UI, providing an experience similar to a full installer.
/// </summary>
public bool LaunchLauncherForApplyUpdate()
{
try
{
var launcherExeName = OperatingSystem.IsWindows()
? "LanMountainDesktop.Launcher.exe"
: "LanMountainDesktop.Launcher";
// The Launcher is in the parent directory of the app's base directory
// (app runs from app-{version}/ subdirectory, Launcher is at root)
var appBaseDir = AppContext.BaseDirectory;
var launcherRoot = Path.GetDirectoryName(appBaseDir.TrimEnd(Path.DirectorySeparatorChar, Path.AltDirectorySeparatorChar));
if (string.IsNullOrWhiteSpace(launcherRoot))
{
launcherRoot = appBaseDir;
}
var launcherPath = Path.Combine(launcherRoot, launcherExeName);
if (!File.Exists(launcherPath))
{
AppLogger.Warn("UpdateWorkflow", $"Launcher executable not found at '{launcherPath}'. Falling back to next-startup apply.");
return false;
}
var startInfo = new ProcessStartInfo
{
FileName = launcherPath,
Arguments = $"apply-update --app-root \"{launcherRoot}\"",
UseShellExecute = false,
WorkingDirectory = launcherRoot
};
Process.Start(startInfo);
AppLogger.Info("UpdateWorkflow", $"Launched Launcher for apply-update: {launcherPath}");
return true;
}
catch (Exception ex)
{
AppLogger.Warn("UpdateWorkflow", $"Failed to launch Launcher for apply-update: {ex.Message}");
return false;
}
}
public void ClearPendingUpdate()
{
var state = _settingsFacade.Update.Get();

View File

@@ -1561,6 +1561,9 @@ public sealed partial class UpdateSettingsPageViewModel : ViewModelBase
[ObservableProperty]
private string _lastCheckedLabel = string.Empty;
[ObservableProperty]
private string _updateTypeLabel = string.Empty;
[ObservableProperty]
private string _checkForUpdatesButtonText = string.Empty;
@@ -1594,6 +1597,9 @@ public sealed partial class UpdateSettingsPageViewModel : ViewModelBase
[ObservableProperty]
private bool _hasPendingInstaller;
[ObservableProperty]
private string _pendingUpdateTypeText = string.Empty;
[ObservableProperty]
private double _downloadThreadsSliderValue = UpdateSettingsValues.DefaultDownloadThreads;
@@ -1987,6 +1993,26 @@ public sealed partial class UpdateSettingsPageViewModel : ViewModelBase
[RelayCommand(CanExecute = nameof(CanInstallPendingUpdate))]
private void InstallPendingUpdate()
{
// For delta updates, launch the Launcher with apply-update command
if (_updateWorkflowService.IsPendingDeltaUpdate())
{
var launchResult = _updateWorkflowService.LaunchLauncherForApplyUpdate();
if (launchResult)
{
UpdateStatus = L(
"settings.update.status_delta_applying",
"Applying incremental update. The app will close for update.");
HasPendingInstaller = false;
return;
}
UpdateStatus = L(
"settings.update.status_delta_launch_failed",
"Failed to launch updater for incremental update.");
return;
}
// For full installer, launch the installer executable
var result = _updateWorkflowService.LaunchPendingInstallerNow();
if (result.Success)
{
@@ -2083,6 +2109,7 @@ public sealed partial class UpdateSettingsPageViewModel : ViewModelBase
LatestVersionLabel = L("settings.update.latest_version_label", "Latest Release");
PublishedAtLabel = L("settings.update.published_at_label", "Published At");
LastCheckedLabel = L("settings.update.last_checked_label", "Last Checked");
UpdateTypeLabel = L("settings.update.type_label", "Update Type");
StableChannelText = L("settings.update.channel_stable", "Stable");
PreviewChannelText = L("settings.update.channel_preview", "Preview");
GitHubSourceText = L("settings.update.source_github", "GitHub");
@@ -2130,6 +2157,7 @@ public sealed partial class UpdateSettingsPageViewModel : ViewModelBase
HasPendingInstaller = pending is not null;
if (pending is null)
{
PendingUpdateTypeText = string.Empty;
return;
}
@@ -2137,6 +2165,9 @@ public sealed partial class UpdateSettingsPageViewModel : ViewModelBase
IsLatestVersionVisible = !string.IsNullOrWhiteSpace(LatestVersionText);
PublishedAtText = pending.PublishedAt is null ? string.Empty : FormatTimestamp(pending.PublishedAt.Value.ToUnixTimeMilliseconds());
IsPublishedAtVisible = !string.IsNullOrWhiteSpace(PublishedAtText);
PendingUpdateTypeText = _updateWorkflowService.IsPendingDeltaUpdate()
? L("settings.update.type_delta", "Incremental Update")
: L("settings.update.type_full", "Full Installer");
UpdateStatus = BuildPendingReadyStatus();
}
@@ -2165,7 +2196,7 @@ public sealed partial class UpdateSettingsPageViewModel : ViewModelBase
private async Task DownloadLatestReleaseCoreAsync(UpdateCheckResult? result, bool invokedFromCheck)
{
if (result is null || !result.Success || !result.IsUpdateAvailable || result.PreferredAsset is null)
if (result is null || !result.Success || !result.IsUpdateAvailable)
{
return;
}
@@ -2176,7 +2207,6 @@ public sealed partial class UpdateSettingsPageViewModel : ViewModelBase
IsDownloadProgressVisible = true;
DownloadProgressValue = 0;
DownloadProgressText = L("settings.update.download_progress_idle", "Download progress: -");
UpdateStatus = L("settings.update.status_downloading", "Downloading installer...");
var progress = new Progress<double>(value =>
{
@@ -2187,7 +2217,35 @@ public sealed partial class UpdateSettingsPageViewModel : ViewModelBase
DownloadProgressValue);
});
var downloadResult = await _updateWorkflowService.DownloadReleaseAsync(result, progress);
UpdateDownloadResult downloadResult;
// Prefer delta update if available (smaller download, faster)
if (result.Release is not null && UpdateWorkflowService.IsDeltaUpdateAvailable(result.Release))
{
UpdateStatus = L("settings.update.status_downloading_delta", "Downloading incremental update...");
downloadResult = await _updateWorkflowService.DownloadDeltaUpdateAsync(result, progress);
if (!downloadResult.Success)
{
// Delta download failed, fall back to full installer
AppLogger.Warn("UpdateSettings", $"Delta update download failed: {downloadResult.ErrorMessage}. Falling back to full installer.");
if (result.PreferredAsset is not null)
{
UpdateStatus = L("settings.update.status_downloading", "Downloading installer...");
downloadResult = await _updateWorkflowService.DownloadReleaseAsync(result, progress);
}
}
}
else if (result.PreferredAsset is not null)
{
UpdateStatus = L("settings.update.status_downloading", "Downloading installer...");
downloadResult = await _updateWorkflowService.DownloadReleaseAsync(result, progress);
}
else
{
UpdateStatus = L("settings.update.status_asset_missing", "A new release is available, but no compatible installer was found.");
return;
}
if (!downloadResult.Success)
{
UpdateStatus = string.Format(

View File

@@ -65,7 +65,7 @@
</Grid>
<Grid ColumnDefinitions="Auto,*"
RowDefinitions="Auto,Auto"
RowDefinitions="Auto,Auto,Auto"
ColumnSpacing="20"
RowSpacing="16">
<StackPanel Grid.Row="0"
@@ -106,6 +106,16 @@
<TextBlock Classes="update-kv-value"
Text="{Binding LastCheckedText}" />
</StackPanel>
<StackPanel Grid.Row="2"
Grid.Column="0"
Spacing="4"
IsVisible="{Binding HasPendingInstaller}">
<TextBlock Classes="update-kv-label"
Text="{Binding UpdateTypeLabel}" />
<TextBlock Classes="update-kv-value"
Text="{Binding PendingUpdateTypeText}" />
</StackPanel>
</Grid>
<StackPanel Spacing="12"

178
docs/AOT_PUBLISH.md Normal file
View File

@@ -0,0 +1,178 @@
# Launcher AOT 单文件发布指南
## 什么是 AOT
AOTAhead-of-Time编译将 .NET 代码在构建时直接编译为本地机器码,而不是在运行时通过 JIT 编译。
### AOT 的优势
| 特性 | JIT 模式 | AOT 模式 |
|------|---------|---------|
| 启动速度 | 慢(需要编译) | 快(直接执行) |
| 依赖文件 | 多(.dll, runtimeconfig.json | 少(单文件) |
| 需要 .NET Runtime | 是 | 否 |
| 文件体积 | 小 | 稍大(但单文件更方便) |
| 反编译难度 | 容易 | 困难 |
## 发布方式
### 方式一:使用 PowerShell 脚本(推荐)
```powershell
# 默认发布win-x64单文件自包含
.\scripts\Publish-AOT.ps1
# 指定运行时
.\scripts\Publish-AOT.ps1 -RuntimeIdentifier win-x64
# 不压缩(体积更大但启动更快)
.\scripts\Publish-AOT.ps1 -Compress:$false
```
### 方式二:使用 dotnet CLI
```bash
# 基本 AOT 发布
dotnet publish LanMountainDesktop.Launcher/LanMountainDesktop.Launcher.csproj `
-c Release `
-r win-x64 `
--self-contained `
-p:PublishAot=true `
-p:PublishSingleFile=true `
-p:EnableCompressionInSingleFile=true
# 输出目录
# bin/Release/net10.0/win-x64/publish/
```
### 方式三:使用 MSBuild
```bash
msbuild LanMountainDesktop.Launcher/LanMountainDesktop.Launcher.csproj `
/t:Publish `
/p:Configuration=Release `
/p:RuntimeIdentifier=win-x64 `
/p:PublishAot=true `
/p:PublishSingleFile=true
```
## 支持的运行时
| 运行时标识符 | 说明 |
|-------------|------|
| `win-x64` | Windows 64位推荐 |
| `win-x86` | Windows 32位 |
| `win-arm64` | Windows ARM64 |
| `linux-x64` | Linux 64位 |
| `linux-arm64` | Linux ARM64 |
| `osx-x64` | macOS 64位 |
| `osx-arm64` | macOS ARM64 (Apple Silicon) |
## 文件体积对比
### 普通发布(非 AOT
```
LanMountainDesktop.Launcher.exe 150 KB
LanMountainDesktop.Launcher.dll 200 KB
Avalonia.dll 1.2 MB
...(数十个依赖文件)
总计: ~15 MB
```
### AOT 单文件发布
```
LanMountainDesktop.Launcher.exe 8-12 MB单文件包含所有依赖
```
## 注意事项
### 1. 修剪Trimming
AOT 会自动移除未使用的代码以减小体积。某些反射代码可能需要特殊处理:
```csharp
// 如果类型被反射使用,需要保留
[DynamicallyAccessedMembers(DynamicallyAccessedMemberTypes.All)]
public class MyClass { }
```
### 2. Avalonia 兼容性
- ✅ Avalonia 11.x 完全支持 AOT
- ✅ 使用 Compiled Bindings已在项目中启用
- ✅ 避免动态 XAML 加载
### 3. Json 序列化
使用 `JsonSerializer` 时需要源生成器:
```csharp
[JsonSerializable(typeof(MyType))]
internal partial class MyJsonContext : JsonSerializerContext { }
```
### 4. 单文件特殊处理
某些文件需要嵌入到单文件中:
```xml
<ItemGroup>
<EmbeddedResource Include="Assets\logo.ico" />
</ItemGroup>
```
## 故障排除
### 发布失败
1. **检查 .NET SDK 版本**
```bash
dotnet --version # 需要 10.0 或更高
```
2. **安装 AOT 工作负载**
```bash
dotnet workload install wasm-tools # 如果需要 WebAssembly AOT
```
3. **Visual Studio 要求**
- 需要 VS 2022 17.8+ 或 VS Code + C# Dev Kit
### 运行时错误
1. **缺少类型**
- 在 `.csproj` 中添加 `<TrimmerRootAssembly>`
2. **反射失败**
- 使用 `[DynamicallyAccessedMembers]` 标记
3. **DllNotFoundException**
- 确保所有 native 库都包含在发布中
## 性能对比
| 指标 | JIT | AOT | 提升 |
|------|-----|-----|------|
| 启动时间 | 2-3 秒 | 0.5-1 秒 | 2-3x |
| 内存占用 | 较高 | 较低 | 20-30% |
| 首次响应 | 慢 | 快 | 显著 |
## 推荐配置
对于 Launcher 项目,推荐使用以下配置:
```xml
<PublishAot>true</PublishAot>
<PublishTrimmed>true</PublishTrimmed>
<TrimMode>partial</TrimMode>
<SelfContained>true</SelfContained>
<PublishSingleFile>true</PublishSingleFile>
<IncludeNativeLibrariesForSelfExtract>true</IncludeNativeLibrariesForSelfExtract>
<EnableCompressionInSingleFile>true</EnableCompressionInSingleFile>
```
这样发布的结果:
- ✅ 单文件可执行
- ✅ 无需 .NET Runtime
- ✅ 启动速度快
- ✅ 文件体积合理8-12 MB

78
docs/HOST_DISCOVERY.md Normal file
View File

@@ -0,0 +1,78 @@
# 主程序发现配置指南
Launcher 支持灵活的主程序发现机制,可以通过多种方式配置主程序路径。
## 发现优先级
1. **环境变量** (`LMD_HOST_PATH`) - 最高优先级
2. **配置文件** (`host-discovery.json`)
3. **开发模式保存的路径** - 通过调试窗口选择
4. **部署目录** (`app-*`)
5. **开发路径** - 自动搜索解决方案中的 bin 目录
6. **额外配置路径** - 自定义搜索路径
7. **递归搜索** - 如果启用
## 配置方式
### 1. 环境变量
设置 `LMD_HOST_PATH` 环境变量指向主程序可执行文件:
```powershell
$env:LMD_HOST_PATH = "C:\MyApp\LanMountainDesktop.exe"
```
### 2. 配置文件
在应用根目录创建 `host-discovery.json`
```json
{
"HostPath": "C:\\Custom\\Path\\LanMountainDesktop.exe",
"AdditionalPaths": [
"${AppRoot}/custom",
"${UserProfile}/dev/build",
"C:/Program Files/LanMountainDesktop/*"
]
}
```
### 3. 开发模式
在错误窗口中按 `Ctrl+Shift+D` 打开调试窗口,启用开发模式并选择自定义路径。路径会自动保存,下次启动时优先使用。
## 路径变量
配置文件支持以下变量:
- `${AppRoot}` - 应用根目录
- `${BaseDirectory}` - Launcher 所在目录
- `${UserProfile}` - 用户主目录
- `${LocalAppData}` - 本地应用数据目录
## 通配符支持
`AdditionalPaths` 支持通配符:
```json
{
"AdditionalPaths": [
"C:/Builds/*/LanMountainDesktop.exe",
"${AppRoot}/versions/*/app.exe"
]
}
```
## 递归搜索
启用递归搜索可以自动在子目录中查找主程序:
```csharp
var options = new HostDiscoveryOptions
{
RecursiveSearch = true,
MaxRecursionDepth = 3
};
```
注意:递归搜索可能影响启动性能,建议仅在必要时启用。

View File

@@ -0,0 +1,129 @@
# Launcher 打包分发指南
## 目录结构
打包给用户的 Launcher 应该包含以下结构:
```
LanMountainDesktop/
├── LanMountainDesktop.Launcher.exe # 启动器可执行文件
├── LanMountainDesktop.Launcher.dll # 启动器依赖
├── ... # 其他启动器依赖文件
├── app-1.0.0/ # 主程序部署目录
│ ├── LanMountainDesktop.exe # 主程序可执行文件
│ ├── LanMountainDesktop.dll # 主程序依赖
│ ├── version.json # 版本信息文件
│ └── .current # 当前版本标记文件
└── plugins/ # 插件目录(可选)
```
## 打包步骤
### 1. 构建 Launcher
```bash
dotnet build LanMountainDesktop.Launcher/LanMountainDesktop.Launcher.csproj -c Release
```
### 2. 构建主程序
```bash
dotnet build LanMountainDesktop/LanMountainDesktop.csproj -c Release
```
### 3. 创建部署目录
```powershell
# 创建版本目录
New-Item -ItemType Directory -Path "dist/app-1.0.0" -Force
# 复制主程序文件
Copy-Item "LanMountainDesktop/bin/Release/net10.0/*" "dist/app-1.0.0/" -Recurse
# 创建版本标记
New-Item -ItemType File -Path "dist/app-1.0.0/.current" -Force
```
### 4. 复制 Launcher
```powershell
# 复制启动器文件
Copy-Item "LanMountainDesktop.Launcher/bin/Release/net10.0/*" "dist/" -Recurse
```
### 5. 创建安装包
可以使用以下工具创建安装包:
- **Inno Setup** - Windows 安装程序
- **WiX Toolset** - Windows Installer
- **MSIX** - Windows 应用包
- **Zip** - 便携版
## 用户数据存储位置
Launcher 会将用户配置存储在以下位置:
```
%LOCALAPPDATA%\LanMountainDesktop\.launcher\
├── devmode.config # 开发模式状态
└── custom-host-path.config # 自定义主程序路径
```
这些文件:
- **不会**随应用更新而删除
- **不会**随应用卸载而删除(除非用户手动清理)
- 在重装应用后会自动恢复之前的配置
## 生产环境行为
### 正常启动流程
1. 用户双击 `LanMountainDesktop.Launcher.exe`
2. Launcher 查找 `app-*` 目录中的主程序
3. 启动主程序并传递版本信息
4. 主程序显示正确的版本和开发代号
### 更新流程
1. 新版本下载到 `app-{new-version}/`
2. 创建 `.current` 标记指向新版本
3. 旧版本标记为 `.destroy`
4. 下次启动时自动使用新版本
## 开发环境配置
### 启用开发模式
1. 启动 Launcher如果找不到主程序会显示错误窗口
2.`Ctrl+Shift+D` 打开调试窗口
3. 勾选"启用开发模式"
4. 选择自定义主程序路径
5. 关闭窗口,配置会自动保存
### 开发模式优先级
开发模式的配置**不会**影响生产环境:
- 生产环境优先使用 `app-*` 目录
- 开发模式仅在找不到部署目录时生效
- 开发模式配置保存在用户数据目录,不影响其他用户
## 故障排除
### Launcher 找不到主程序
1. 检查 `app-*` 目录是否存在
2. 检查 `.current` 标记文件是否存在
3. 检查主程序可执行文件是否存在
4. 查看 `%LOCALAPPDATA%\LanMountainDesktop\.launcher\` 下的配置
### 版本信息不正确
1. 检查 `app-*/version.json` 是否存在
2. 检查 `version.json` 内容是否正确
3. 重新构建主程序生成新的 `version.json`
### 开发模式配置丢失
1. 检查 `%LOCALAPPDATA%\LanMountainDesktop\.launcher\` 目录权限
2. 检查磁盘空间是否充足
3. 手动删除配置目录后重新配置

View File

@@ -0,0 +1,28 @@
# 生成版本信息文件
param(
[Parameter(Mandatory=$true)]
[string]$OutputPath,
[Parameter(Mandatory=$true)]
[string]$Version,
[Parameter(Mandatory=$false)]
[string]$Codename = "Administrate"
)
$versionInfo = @{
Version = $Version
Codename = $Codename
}
$json = $versionInfo | ConvertTo-Json -Compress
$dir = Split-Path -Parent $OutputPath
if (!(Test-Path $dir)) {
New-Item -ItemType Directory -Path $dir -Force | Out-Null
}
Set-Content -Path $OutputPath -Value $json -Encoding UTF8
Write-Host "Generated version file: $OutputPath" -ForegroundColor Green
Write-Host " Version: $Version" -ForegroundColor Gray
Write-Host " Codename: $Codename" -ForegroundColor Gray

137
scripts/Publish-AOT.ps1 Normal file
View File

@@ -0,0 +1,137 @@
# Launcher AOT 单文件发布脚本
param(
[Parameter(Mandatory=$false)]
[string]$Configuration = "Release",
[Parameter(Mandatory=$false)]
[string]$RuntimeIdentifier = "win-x64",
[Parameter(Mandatory=$false)]
[string]$OutputDir = "",
[Parameter(Mandatory=$false)]
[switch]$SelfContained = $true,
[Parameter(Mandatory=$false)]
[switch]$SingleFile = $true,
[Parameter(Mandatory=$false)]
[switch]$Compress = $true
)
$ErrorActionPreference = "Stop"
# 设置默认输出目录
if ([string]::IsNullOrWhiteSpace($OutputDir)) {
$OutputDir = "..\publish\aot\$RuntimeIdentifier"
}
$projectPath = "..\LanMountainDesktop.Launcher\LanMountainDesktop.Launcher.csproj"
$absoluteOutputDir = Resolve-Path $OutputDir -ErrorAction SilentlyContinue
if (-not $absoluteOutputDir) {
$absoluteOutputDir = Join-Path (Get-Location) $OutputDir
}
Write-Host "========================================" -ForegroundColor Cyan
Write-Host " Launcher AOT 单文件发布" -ForegroundColor Cyan
Write-Host "========================================" -ForegroundColor Cyan
Write-Host ""
Write-Host "配置信息:" -ForegroundColor Yellow
Write-Host " 项目: $projectPath"
Write-Host " 配置: $Configuration"
Write-Host " 运行时: $RuntimeIdentifier"
Write-Host " 输出目录: $absoluteOutputDir"
Write-Host " 自包含: $SelfContained"
Write-Host " 单文件: $SingleFile"
Write-Host " 压缩: $Compress"
Write-Host ""
# 清理输出目录
if (Test-Path $absoluteOutputDir) {
Write-Host "清理旧输出目录..." -ForegroundColor Yellow
Remove-Item -Path $absoluteOutputDir -Recurse -Force
}
New-Item -ItemType Directory -Path $absoluteOutputDir -Force | Out-Null
# 构建发布参数
$publishArgs = @(
"publish",
$projectPath,
"-c", $Configuration,
"-r", $RuntimeIdentifier,
"-o", $absoluteOutputDir,
"-p:PublishAot=true",
"-p:PublishTrimmed=true",
"-p:TrimMode=partial"
)
if ($SelfContained) {
$publishArgs += "--self-contained"
}
if ($SingleFile) {
$publishArgs += "-p:PublishSingleFile=true"
$publishArgs += "-p:IncludeNativeLibrariesForSelfExtract=true"
}
if ($Compress) {
$publishArgs += "-p:EnableCompressionInSingleFile=true"
}
Write-Host "开始发布..." -ForegroundColor Green
Write-Host "命令: dotnet $([string]::Join(' ', $publishArgs))" -ForegroundColor Gray
Write-Host ""
try {
& dotnet @publishArgs
if ($LASTEXITCODE -ne 0) {
throw "发布失败,退出代码: $LASTEXITCODE"
}
Write-Host ""
Write-Host "========================================" -ForegroundColor Green
Write-Host " 发布成功!" -ForegroundColor Green
Write-Host "========================================" -ForegroundColor Green
Write-Host ""
# 显示输出文件
$outputFiles = Get-ChildItem -Path $absoluteOutputDir -File
Write-Host "输出文件:" -ForegroundColor Yellow
foreach ($file in $outputFiles) {
$size = if ($file.Length -gt 1MB) {
"{0:N2} MB" -f ($file.Length / 1MB)
} else {
"{0:N2} KB" -f ($file.Length / 1KB)
}
Write-Host " $($file.Name) - $size"
}
# 验证单文件
$exeFile = Get-ChildItem -Path $absoluteOutputDir -Filter "*.exe" | Select-Object -First 1
if ($exeFile) {
Write-Host ""
Write-Host "可执行文件: $($exeFile.FullName)" -ForegroundColor Green
# 检查是否为单文件
if ($SingleFile -and $outputFiles.Count -eq 1) {
Write-Host "✓ 单文件发布成功!" -ForegroundColor Green
} elseif ($SingleFile) {
Write-Host "⚠ 警告: 发现 $($outputFiles.Count) 个文件,可能不是完全的单文件" -ForegroundColor Yellow
}
}
Write-Host ""
Write-Host "使用说明:" -ForegroundColor Cyan
Write-Host " 1. 将 $($exeFile.Name) 复制到目标机器"
Write-Host " 2. 确保目录结构包含 app-* 文件夹"
Write-Host " 3. 直接运行即可,无需安装 .NET Runtime"
} catch {
Write-Host ""
Write-Host "========================================" -ForegroundColor Red
Write-Host " 发布失败!" -ForegroundColor Red
Write-Host "========================================" -ForegroundColor Red
Write-Host "错误: $_" -ForegroundColor Red
exit 1
}

59
test-launcher.ps1 Normal file
View File

@@ -0,0 +1,59 @@
# 测试 Launcher 在发布版环境下的行为
$ErrorActionPreference = "Stop"
$testDir = "C:\Temp\LanMountainDesktop-Test"
$launcherSource = "C:\Users\USER154971\Documents\GitHub\LanMountainDesktop\LanMountainDesktop.Launcher\bin\Release\net10.0"
$appSource = "C:\Users\USER154971\Documents\GitHub\LanMountainDesktop\LanMountainDesktop\bin\Release\net10.0"
Write-Host "=== Launcher 发布版环境测试 ===" -ForegroundColor Cyan
# 清理并创建测试目录
if (Test-Path $testDir) {
Remove-Item -Path $testDir -Recurse -Force
}
New-Item -ItemType Directory -Path $testDir -Force | Out-Null
New-Item -ItemType Directory -Path "$testDir\app-1.0.0" -Force | Out-Null
Write-Host "测试目录: $testDir" -ForegroundColor Yellow
# 复制 Launcher 文件
Write-Host "复制 Launcher 文件..." -ForegroundColor Yellow
Copy-Item -Path "$launcherSource\*" -Destination $testDir -Recurse -Force
# 复制主程序文件到 app-1.0.0 目录
Write-Host "复制主程序文件到 app-1.0.0..." -ForegroundColor Yellow
$appFiles = @(
"LanMountainDesktop.exe",
"LanMountainDesktop.dll",
"LanMountainDesktop.deps.json",
"LanMountainDesktop.runtimeconfig.json"
)
foreach ($file in $appFiles) {
$sourcePath = "$appSource\$file"
if (Test-Path $sourcePath) {
Copy-Item -Path $sourcePath -Destination "$testDir\app-1.0.0" -Force
Write-Host " 复制: $file" -ForegroundColor Gray
} else {
Write-Host " 跳过: $file (不存在)" -ForegroundColor DarkGray
}
}
# 创建 .current 标记文件
New-Item -ItemType File -Path "$testDir\app-1.0.0\.current" -Force | Out-Null
# 列出目录结构
Write-Host "`n目录结构:" -ForegroundColor Cyan
Get-ChildItem -Path $testDir -Recurse | Select-Object FullName | Format-Table -AutoSize
# 运行 Launcher
Write-Host "`n运行 Launcher..." -ForegroundColor Green
$launcherPath = "$testDir\LanMountainDesktop.Launcher.exe"
if (Test-Path $launcherPath) {
Write-Host "启动: $launcherPath" -ForegroundColor Green
Start-Process -FilePath $launcherPath -WorkingDirectory $testDir -Wait
} else {
Write-Host "错误: 找不到 Launcher 可执行文件" -ForegroundColor Red
}
Write-Host "`n测试完成" -ForegroundColor Cyan