diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml
index 430b114..a7adc4e 100644
--- a/.github/workflows/release.yml
+++ b/.github/workflows/release.yml
@@ -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)
diff --git a/LanMountainDesktop.Launcher/App.axaml.cs b/LanMountainDesktop.Launcher/App.axaml.cs
index 8c1d949..2098c2a 100644
--- a/LanMountainDesktop.Launcher/App.axaml.cs
+++ b/LanMountainDesktop.Launcher/App.axaml.cs
@@ -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)
+ ///
+ /// 处理界面预览命令
+ ///
+ 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;
+ }
+ }
+
+ ///
+ /// 模拟 Splash 窗口预览
+ ///
+ 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));
+ }
+
+ ///
+ /// 模拟 Update 窗口预览
+ ///
+ 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
+ };
+ }
+
+ ///
+ /// 模拟 OOBE 窗口预览
+ ///
+ 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));
+ }
+
+ ///
+ /// 等待窗口关闭
+ ///
+ 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);
}
+
+ ///
+ /// apply-update 模式:执行增量更新和插件升级,完成后自动退出
+ ///
+ 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);
+ }
+
+
}
diff --git a/LanMountainDesktop.Launcher/Assets/logo.ico b/LanMountainDesktop.Launcher/Assets/logo.ico
new file mode 100644
index 0000000..aa1524d
Binary files /dev/null and b/LanMountainDesktop.Launcher/Assets/logo.ico differ
diff --git a/LanMountainDesktop.Launcher/CommandContext.cs b/LanMountainDesktop.Launcher/CommandContext.cs
index 29dccff..1af1487 100644
--- a/LanMountainDesktop.Launcher/CommandContext.cs
+++ b/LanMountainDesktop.Launcher/CommandContext.cs
@@ -10,16 +10,30 @@ internal sealed class CommandContext
public IReadOnlyDictionary Options { get; }
+ ///
+ /// 原始命令行参数,用于转发给主程序
+ ///
+ public IReadOnlyList RawArgs { get; }
+
public bool IsLegacyPluginInstall =>
Options.ContainsKey("source") &&
Options.ContainsKey("plugins-dir") &&
Options.ContainsKey("result");
- private CommandContext(string command, string subCommand, Dictionary options)
+ ///
+ /// 是否处于调试模式(从 Rider/VS 等 IDE 启动)
+ /// 仅当明确指定 --debug 参数或调试器附加时才启用
+ ///
+ public bool IsDebugMode =>
+ Options.ContainsKey("debug") ||
+ System.Diagnostics.Debugger.IsAttached;
+
+ private CommandContext(string command, string subCommand, Dictionary 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)
diff --git a/LanMountainDesktop.Launcher/LanMountainDesktop.Launcher.AOT.props b/LanMountainDesktop.Launcher/LanMountainDesktop.Launcher.AOT.props
new file mode 100644
index 0000000..905d7e4
--- /dev/null
+++ b/LanMountainDesktop.Launcher/LanMountainDesktop.Launcher.AOT.props
@@ -0,0 +1,62 @@
+
+
+
+
+ true
+
+
+ true
+ partial
+
+
+ true
+
+
+ true
+
+
+ true
+
+
+ true
+
+
+ Size
+
+
+ false
+
+
+ win-x64
+
+
+
+
+
+ true
+
+
+ true
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ false
+
+ false
+
+
+ true
+
+
diff --git a/LanMountainDesktop.Launcher/LanMountainDesktop.Launcher.SingleFile.props b/LanMountainDesktop.Launcher/LanMountainDesktop.Launcher.SingleFile.props
new file mode 100644
index 0000000..b01cb5c
--- /dev/null
+++ b/LanMountainDesktop.Launcher/LanMountainDesktop.Launcher.SingleFile.props
@@ -0,0 +1,29 @@
+
+
+
+
+ true
+
+
+ true
+
+
+ true
+
+
+ true
+
+
+ true
+
+
+ true
+ partial
+
+
+ Size
+
+
+ win-x64
+
+
diff --git a/LanMountainDesktop.Launcher/LanMountainDesktop.Launcher.csproj b/LanMountainDesktop.Launcher/LanMountainDesktop.Launcher.csproj
index efe7aa3..112bf4d 100644
--- a/LanMountainDesktop.Launcher/LanMountainDesktop.Launcher.csproj
+++ b/LanMountainDesktop.Launcher/LanMountainDesktop.Launcher.csproj
@@ -1,4 +1,8 @@
+
+
+
+
WinExe
net10.0
@@ -7,6 +11,8 @@
1.0.0
$(Version)
true
+
+ Assets\logo.ico
@@ -23,9 +29,12 @@
-
+
+
+
+
diff --git a/LanMountainDesktop.Launcher/Program.cs b/LanMountainDesktop.Launcher/Program.cs
index c60982b..410b2bd 100644
--- a/LanMountainDesktop.Launcher/Program.cs
+++ b/LanMountainDesktop.Launcher/Program.cs
@@ -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()
diff --git a/LanMountainDesktop.Launcher/Services/Commands.cs b/LanMountainDesktop.Launcher/Services/Commands.cs
index ac8f8bc..d4d8896 100644
--- a/LanMountainDesktop.Launcher/Services/Commands.cs
+++ b/LanMountainDesktop.Launcher/Services/Commands.cs
@@ -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;
}
}
diff --git a/LanMountainDesktop.Launcher/Services/DeferredSplashStageReporter.cs b/LanMountainDesktop.Launcher/Services/DeferredSplashStageReporter.cs
index c64a390..ea8de25 100644
--- a/LanMountainDesktop.Launcher/Services/DeferredSplashStageReporter.cs
+++ b/LanMountainDesktop.Launcher/Services/DeferredSplashStageReporter.cs
@@ -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);
+ }
+ }
}
diff --git a/LanMountainDesktop.Launcher/Services/DeploymentLocator.cs b/LanMountainDesktop.Launcher/Services/DeploymentLocator.cs
index c94be13..c1a17d1 100644
--- a/LanMountainDesktop.Launcher/Services/DeploymentLocator.cs
+++ b/LanMountainDesktop.Launcher/Services/DeploymentLocator.cs
@@ -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
+ {
+ // 可以通过配置文件或环境变量添加更多路径
+ "${AppRoot}",
+ "${AppRoot}/..",
+ "${BaseDirectory}/../..",
+ }
+ };
+
+ var locator = new FlexibleHostLocator(_appRoot, options);
+ var result = locator.ResolveHostExecutablePath();
+
+ if (result != null)
+ {
+ return result;
+ }
+
+ // 回退到旧逻辑(作为备选)
+ return ResolveHostExecutablePathLegacy();
+ }
+
+ ///
+ /// 传统的主程序路径解析(作为备选)
+ ///
+ 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];
}
+
+ ///
+ /// 从部署目录读取版本信息
+ ///
+ public AppVersionInfo GetVersionInfo()
+ {
+ var deploymentDir = FindCurrentDeploymentDirectory();
+ if (!string.IsNullOrWhiteSpace(deploymentDir))
+ {
+ var versionFile = Path.Combine(deploymentDir, "version.json");
+ if (File.Exists(versionFile))
+ {
+ try
+ {
+ var json = File.ReadAllText(versionFile);
+ var info = JsonSerializer.Deserialize(json);
+ if (info is not null)
+ {
+ return info;
+ }
+ }
+ catch
+ {
+ // 忽略读取失败,回退到默认值
+ }
+ }
+ }
+
+ // 回退:从目录名解析版本,使用默认开发代号
+ return new AppVersionInfo
+ {
+ Version = GetCurrentVersion(),
+ Codename = "Administrate" // 默认开发代号
+ };
+ }
}
diff --git a/LanMountainDesktop.Launcher/Services/FlexibleHostLocator.cs b/LanMountainDesktop.Launcher/Services/FlexibleHostLocator.cs
new file mode 100644
index 0000000..1b9b667
--- /dev/null
+++ b/LanMountainDesktop.Launcher/Services/FlexibleHostLocator.cs
@@ -0,0 +1,612 @@
+using System.Diagnostics;
+using System.Text.Json;
+
+namespace LanMountainDesktop.Launcher.Services;
+
+///
+/// 灵活的主程序定位器
+///
+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();
+ }
+
+ ///
+ /// 解析主程序可执行文件路径
+ ///
+ 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;
+ }
+
+ ///
+ /// 从环境变量获取路径
+ ///
+ private string? GetPathFromEnvironment()
+ {
+ if (string.IsNullOrWhiteSpace(_options.CustomPathEnvVar))
+ {
+ return null;
+ }
+
+ var path = Environment.GetEnvironmentVariable(_options.CustomPathEnvVar);
+ return path;
+ }
+
+ ///
+ /// 从配置文件获取路径
+ ///
+ private string? GetPathFromConfigFile()
+ {
+ if (string.IsNullOrWhiteSpace(_options.ConfigFileName))
+ {
+ return null;
+ }
+
+ var configPath = Path.Combine(_appRoot, _options.ConfigFileName);
+ if (!File.Exists(configPath))
+ {
+ return null;
+ }
+
+ try
+ {
+ var json = File.ReadAllText(configPath);
+ var config = JsonSerializer.Deserialize(json);
+ if (config?.HostPath != null && File.Exists(config.HostPath))
+ {
+ return config.HostPath;
+ }
+ }
+ catch
+ {
+ // 忽略配置文件读取错误
+ }
+
+ return null;
+ }
+
+ ///
+ /// 搜索部署目录
+ ///
+ 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;
+ }
+ }
+
+ ///
+ /// 搜索便携模式位置(Launcher 同级目录)
+ ///
+ 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;
+ }
+
+ ///
+ /// 搜索附近目录(灵活查找,适用于各种部署场景)
+ ///
+ private string? SearchNearbyDirectories(SearchContext context)
+ {
+ try
+ {
+ var searchDirs = new List();
+
+ // 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;
+ }
+
+ ///
+ /// 搜索开发路径
+ ///
+ private string? SearchDevelopmentPaths(SearchContext context)
+ {
+ // 获取 Launcher 所在目录
+ var launcherDir = AppContext.BaseDirectory;
+
+ // 动态构建可能的开发路径(支持不同的项目结构)
+ var possiblePaths = new List();
+
+ // 从解决方案根目录搜索(支持不同的解决方案结构)
+ 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;
+ }
+
+ ///
+ /// 搜索额外的配置路径
+ ///
+ 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;
+ }
+
+ ///
+ /// 递归搜索
+ ///
+ 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;
+ }
+
+ ///
+ /// 递归搜索目录
+ ///
+ 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;
+ }
+
+ ///
+ /// 查找解决方案根目录
+ ///
+ 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;
+ }
+
+ ///
+ /// 搜索 bin 目录
+ ///
+ private IEnumerable SearchBinDirectories(string root, string executableName)
+ {
+ var results = new List();
+
+ 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;
+ }
+
+ ///
+ /// 验证路径并返回
+ ///
+ private string? ValidateAndReturn(string path, string source)
+ {
+ if (File.Exists(path))
+ {
+ Debug.WriteLine($"Found host executable from {source}: {path}");
+ return path;
+ }
+
+ // 尝试添加 .exe(Windows)
+ 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;
+ }
+
+ ///
+ /// 获取可执行文件名
+ ///
+ private string GetExecutableName()
+ {
+ var name = _options.ExecutableName;
+ if (OperatingSystem.IsWindows() && !name.EndsWith(".exe", StringComparison.OrdinalIgnoreCase))
+ {
+ name += ".exe";
+ }
+ return name;
+ }
+
+ ///
+ /// 展开路径变量
+ ///
+ 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));
+ }
+
+ ///
+ /// 从目录名解析版本
+ ///
+ 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);
+ }
+
+ ///
+ /// 搜索上下文
+ ///
+ private class SearchContext
+ {
+ public required string ExecutableName { get; set; }
+ public required string AppRoot { get; set; }
+ public required HostDiscoveryOptions Options { get; set; }
+ }
+
+ ///
+ /// 发现配置文件
+ ///
+ private class HostDiscoveryConfig
+ {
+ public string? HostPath { get; set; }
+ public List? AdditionalPaths { get; set; }
+ }
+}
diff --git a/LanMountainDesktop.Launcher/Services/HostDiscoveryOptions.cs b/LanMountainDesktop.Launcher/Services/HostDiscoveryOptions.cs
new file mode 100644
index 0000000..ea031fa
--- /dev/null
+++ b/LanMountainDesktop.Launcher/Services/HostDiscoveryOptions.cs
@@ -0,0 +1,47 @@
+namespace LanMountainDesktop.Launcher.Services;
+
+///
+/// 主程序发现选项
+///
+public sealed class HostDiscoveryOptions
+{
+ ///
+ /// 可执行文件名(Windows 下自动添加 .exe)
+ ///
+ public string ExecutableName { get; set; } = "LanMountainDesktop";
+
+ ///
+ /// 额外的搜索路径(支持通配符)
+ ///
+ public List AdditionalSearchPaths { get; set; } = new();
+
+ ///
+ /// 是否递归搜索子目录
+ ///
+ public bool RecursiveSearch { get; set; } = false;
+
+ ///
+ /// 递归搜索的最大深度
+ ///
+ public int MaxRecursionDepth { get; set; } = 3;
+
+ ///
+ /// 环境变量名称,用于指定自定义路径
+ ///
+ public string? CustomPathEnvVar { get; set; } = "LMD_HOST_PATH";
+
+ ///
+ /// 配置文件路径(相对于 app root)
+ ///
+ public string? ConfigFileName { get; set; } = "host-discovery.json";
+
+ ///
+ /// 是否优先使用开发模式配置
+ ///
+ public bool PreferDevModeConfig { get; set; } = true;
+
+ ///
+ /// 搜索超时(毫秒)
+ ///
+ public int SearchTimeoutMs { get; set; } = 5000;
+}
diff --git a/LanMountainDesktop.Launcher/Services/ISplashStageReporter.cs b/LanMountainDesktop.Launcher/Services/ISplashStageReporter.cs
index b98a531..38bca81 100644
--- a/LanMountainDesktop.Launcher/Services/ISplashStageReporter.cs
+++ b/LanMountainDesktop.Launcher/Services/ISplashStageReporter.cs
@@ -3,4 +3,9 @@ namespace LanMountainDesktop.Launcher.Services;
internal interface ISplashStageReporter
{
void Report(string stage, string message);
+
+ ///
+ /// 报告阶段和进度(0-100)
+ ///
+ void ReportStage(string stage, int progress);
}
diff --git a/LanMountainDesktop.Launcher/Services/Ipc/LauncherIpcServer.cs b/LanMountainDesktop.Launcher/Services/Ipc/LauncherIpcServer.cs
index 61156a4..d526bbe 100644
--- a/LanMountainDesktop.Launcher/Services/Ipc/LauncherIpcServer.cs
+++ b/LanMountainDesktop.Launcher/Services/Ipc/LauncherIpcServer.cs
@@ -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;
///
/// Launcher IPC 服务端 - 接收主程序的启动进度报告
+/// 采用持久连接 + 长度前缀协议,支持客户端在同一连接上多次发送消息。
+/// 跨平台实现:Windows 使用命名管道,Linux/macOS 使用 Unix 域套接字
///
public class LauncherIpcServer : IDisposable
{
private readonly CancellationTokenSource _cts = new();
- private NamedPipeServerStream? _pipeServer;
private readonly Action _onProgress;
private Task? _listenTask;
-
+ private NamedPipeServerStream? _currentPipe;
+
+ ///
+ /// 协议:每条消息以 4 字节小端 int32 长度前缀开头,后跟 UTF-8 JSON 正文。
+ /// 这在 Windows Message 模式和 Unix Byte 模式下均能可靠工作。
+ ///
+ private const int LengthPrefixSize = 4;
+
public LauncherIpcServer(Action onProgress)
{
_onProgress = onProgress;
}
-
+
///
/// 启动 IPC 服务端监听
///
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(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);
+ }
}
-
+
+ ///
+ /// 从已连接的管道中持续读取消息,直到连接断开或取消
+ ///
+ private async Task ReadMessagesFromConnectionAsync(NamedPipeServerStream pipe, CancellationToken cancellationToken)
+ {
+ var lengthBuffer = ArrayPool.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.Shared.Rent(payloadLength);
+ try
+ {
+ totalRead = 0;
+ while (totalRead < payloadLength)
+ {
+ var read = await pipe.ReadAsync(payloadBuffer.AsMemory(totalRead, payloadLength - totalRead), cancellationToken);
+ if (read == 0)
+ {
+ return;
+ }
+ totalRead += read;
+ }
+
+ // 3. 反序列化并回调
+ var json = System.Text.Encoding.UTF8.GetString(payloadBuffer, 0, payloadLength);
+ var message = JsonSerializer.Deserialize(json);
+ if (message is not null)
+ {
+ _onProgress(message);
+ }
+ }
+ catch (JsonException)
+ {
+ // 忽略解析错误,继续读取下一条消息
+ }
+ finally
+ {
+ ArrayPool.Shared.Return(payloadBuffer);
+ }
+ }
+ }
+ finally
+ {
+ ArrayPool.Shared.Return(lengthBuffer);
+ }
+ }
+
///
/// 停止 IPC 服务端
///
@@ -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));
diff --git a/LanMountainDesktop.Launcher/Services/LauncherFlowCoordinator.cs b/LanMountainDesktop.Launcher/Services/LauncherFlowCoordinator.cs
index cb710f0..bb1e524 100644
--- a/LanMountainDesktop.Launcher/Services/LauncherFlowCoordinator.cs
+++ b/LanMountainDesktop.Launcher/Services/LauncherFlowCoordinator.cs
@@ -34,15 +34,15 @@ internal sealed class LauncherFlowCoordinator
_oobeSteps = [new WelcomeOobeStep(_oobeStateService)];
}
- public async Task RunAsync()
+ public async Task 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 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);
}
///
@@ -193,19 +280,65 @@ internal sealed class LauncherFlowCoordinator
///
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}");
+ }
+ });
+ }
}
}
}
diff --git a/LanMountainDesktop.Launcher/Services/OobeStateService.cs b/LanMountainDesktop.Launcher/Services/OobeStateService.cs
index 738f997..4ef3759 100644
--- a/LanMountainDesktop.Launcher/Services/OobeStateService.cs
+++ b/LanMountainDesktop.Launcher/Services/OobeStateService.cs
@@ -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");
}
diff --git a/LanMountainDesktop.Launcher/ViewModels/DevDebugWindowViewModel.cs b/LanMountainDesktop.Launcher/ViewModels/DevDebugWindowViewModel.cs
new file mode 100644
index 0000000..e7a0239
--- /dev/null
+++ b/LanMountainDesktop.Launcher/ViewModels/DevDebugWindowViewModel.cs
@@ -0,0 +1,263 @@
+using System.ComponentModel;
+using System.Runtime.CompilerServices;
+using System.Windows.Input;
+
+namespace LanMountainDesktop.Launcher.ViewModels;
+
+///
+/// 开发调试窗口 ViewModel
+///
+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 页面开关
+
+ ///
+ /// 启动画面是否启用实际功能
+ ///
+ public bool IsSplashEnabled
+ {
+ get => _isSplashEnabled;
+ set
+ {
+ if (_isSplashEnabled != value)
+ {
+ _isSplashEnabled = value;
+ OnPropertyChanged();
+ UpdateStatus($"启动画面: {(value ? "功能模式" : "仅查看")}");
+ }
+ }
+ }
+
+ ///
+ /// 错误页面是否启用实际功能
+ ///
+ public bool IsErrorEnabled
+ {
+ get => _isErrorEnabled;
+ set
+ {
+ if (_isErrorEnabled != value)
+ {
+ _isErrorEnabled = value;
+ OnPropertyChanged();
+ UpdateStatus($"错误页面: {(value ? "功能模式" : "仅查看")}");
+ }
+ }
+ }
+
+ ///
+ /// 更新页面是否启用实际功能
+ ///
+ public bool IsUpdateEnabled
+ {
+ get => _isUpdateEnabled;
+ set
+ {
+ if (_isUpdateEnabled != value)
+ {
+ _isUpdateEnabled = value;
+ OnPropertyChanged();
+ UpdateStatus($"更新页面: {(value ? "功能模式" : "仅查看")}");
+ }
+ }
+ }
+
+ ///
+ /// OOBE页面是否启用实际功能
+ ///
+ public bool IsOobeEnabled
+ {
+ get => _isOobeEnabled;
+ set
+ {
+ if (_isOobeEnabled != value)
+ {
+ _isOobeEnabled = value;
+ OnPropertyChanged();
+ UpdateStatus($"OOBE页面: {(value ? "功能模式" : "仅查看")}");
+ }
+ }
+ }
+
+ #endregion
+
+ #region 状态信息
+
+ ///
+ /// 状态消息
+ ///
+ public string StatusMessage
+ {
+ get => _statusMessage;
+ private set
+ {
+ if (_statusMessage != value)
+ {
+ _statusMessage = value;
+ OnPropertyChanged();
+ }
+ }
+ }
+
+ #endregion
+
+ #region 命令
+
+ ///
+ /// 打开启动画面命令
+ ///
+ public ICommand OpenSplashCommand { get; }
+
+ ///
+ /// 打开错误页面命令
+ ///
+ public ICommand OpenErrorCommand { get; }
+
+ ///
+ /// 打开更新页面命令
+ ///
+ public ICommand OpenUpdateCommand { get; }
+
+ ///
+ /// 打开OOBE页面命令
+ ///
+ public ICommand OpenOobeCommand { get; }
+
+ ///
+ /// 全部切换到查看模式命令
+ ///
+ public ICommand SetAllViewOnlyCommand { get; }
+
+ ///
+ /// 全部切换到功能模式命令
+ ///
+ public ICommand SetAllFunctionalCommand { get; }
+
+ ///
+ /// 关闭窗口命令
+ ///
+ public ICommand CloseCommand { get; }
+
+ #endregion
+
+ #region 事件
+
+ ///
+ /// 请求打开启动画面
+ ///
+ public event EventHandler? OpenSplashRequested;
+
+ ///
+ /// 请求打开错误页面
+ ///
+ public event EventHandler? OpenErrorRequested;
+
+ ///
+ /// 请求打开更新页面
+ ///
+ public event EventHandler? OpenUpdateRequested;
+
+ ///
+ /// 请求打开OOBE页面
+ ///
+ public event EventHandler? OpenOobeRequested;
+
+ ///
+ /// 请求关闭窗口
+ ///
+ 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
diff --git a/LanMountainDesktop.Launcher/ViewModels/RelayCommand.cs b/LanMountainDesktop.Launcher/ViewModels/RelayCommand.cs
new file mode 100644
index 0000000..fb0f612
--- /dev/null
+++ b/LanMountainDesktop.Launcher/ViewModels/RelayCommand.cs
@@ -0,0 +1,67 @@
+using System.Windows.Input;
+
+namespace LanMountainDesktop.Launcher.ViewModels;
+
+///
+/// 简单的命令实现
+///
+public class RelayCommand : ICommand
+{
+ private readonly Action _execute;
+ private readonly Func? _canExecute;
+
+ public RelayCommand(Action execute, Func? 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);
+ }
+}
+
+///
+/// 带参数的 RelayCommand
+///
+public class RelayCommand : ICommand
+{
+ private readonly Action _execute;
+ private readonly Predicate? _canExecute;
+
+ public RelayCommand(Action execute, Predicate? 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);
+ }
+}
diff --git a/LanMountainDesktop.Launcher/Views/DevDebugWindow.axaml b/LanMountainDesktop.Launcher/Views/DevDebugWindow.axaml
new file mode 100644
index 0000000..7bae0ad
--- /dev/null
+++ b/LanMountainDesktop.Launcher/Views/DevDebugWindow.axaml
@@ -0,0 +1,182 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/LanMountainDesktop.Launcher/Views/DevDebugWindow.axaml.cs b/LanMountainDesktop.Launcher/Views/DevDebugWindow.axaml.cs
new file mode 100644
index 0000000..0c2554b
--- /dev/null
+++ b/LanMountainDesktop.Launcher/Views/DevDebugWindow.axaml.cs
@@ -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;
+
+///
+/// 开发调试窗口
+///
+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;
+ }
+
+ ///
+ /// 打开启动画面
+ ///
+ private void OnOpenSplashRequested(object? sender, SplashOpenEventArgs e)
+ {
+ var splashWindow = new SplashWindow();
+
+ if (!e.IsFunctional)
+ {
+ // 查看模式:显示模拟内容
+ splashWindow.SetDebugMode(true);
+ }
+
+ splashWindow.Show();
+
+ if (e.IsFunctional)
+ {
+ // 功能模式:模拟正常启动流程
+ _ = SimulateSplashProgress(splashWindow);
+ }
+ }
+
+ ///
+ /// 打开错误页面
+ ///
+ 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();
+ }
+
+ ///
+ /// 打开更新页面
+ ///
+ private void OnOpenUpdateRequested(object? sender, UpdateOpenEventArgs e)
+ {
+ var updateWindow = new UpdateWindow();
+
+ if (!e.IsFunctional)
+ {
+ // 查看模式:显示模拟更新
+ updateWindow.SetDebugMode(true);
+ }
+
+ updateWindow.Show();
+
+ if (e.IsFunctional)
+ {
+ // 功能模式:模拟更新进度
+ _ = SimulateUpdateProgress(updateWindow);
+ }
+ }
+
+ ///
+ /// 打开OOBE页面
+ ///
+ private void OnOpenOobeRequested(object? sender, OobeOpenEventArgs e)
+ {
+ var oobeWindow = new OobeWindow();
+
+ if (!e.IsFunctional)
+ {
+ // 查看模式:显示调试标记(通过标题)
+ oobeWindow.Title = "[调试模式] 欢迎使用阑山桌面";
+ }
+
+ oobeWindow.Show();
+
+ if (e.IsFunctional)
+ {
+ // 功能模式:等待用户点击后自动关闭
+ _ = SimulateOobeProgress(oobeWindow);
+ }
+ }
+
+ ///
+ /// 模拟OOBE流程
+ ///
+ 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}");
+ }
+ }
+
+ ///
+ /// 关闭窗口
+ ///
+ private void OnCloseRequested(object? sender, EventArgs e)
+ {
+ Close();
+ }
+
+ ///
+ /// 模拟启动画面进度
+ ///
+ 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();
+ }
+
+ ///
+ /// 模拟更新进度
+ ///
+ 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);
+ }
+}
diff --git a/LanMountainDesktop.Launcher/Views/ErrorDebugWindow.axaml b/LanMountainDesktop.Launcher/Views/ErrorDebugWindow.axaml
index b09d71e..d840777 100644
--- a/LanMountainDesktop.Launcher/Views/ErrorDebugWindow.axaml
+++ b/LanMountainDesktop.Launcher/Views/ErrorDebugWindow.axaml
@@ -14,7 +14,8 @@
CanResize="False"
WindowStartupLocation="CenterOwner"
Background="{DynamicResource SolidBackgroundFillColorBaseBrush}"
- TransparencyLevelHint="None">
+ TransparencyLevelHint="None"
+ Icon="/Assets/logo.ico">
diff --git a/LanMountainDesktop.Launcher/Views/ErrorDebugWindow.axaml.cs b/LanMountainDesktop.Launcher/Views/ErrorDebugWindow.axaml.cs
index 6f4c47c..c7f51e2 100644
--- a/LanMountainDesktop.Launcher/Views/ErrorDebugWindow.axaml.cs
+++ b/LanMountainDesktop.Launcher/Views/ErrorDebugWindow.axaml.cs
@@ -11,6 +11,7 @@ namespace LanMountainDesktop.Launcher.Views;
public partial class ErrorDebugWindow : Window
{
private string? _selectedHostPath;
+ private bool _isInitialized = false;
///
/// 是否启用了开发模式
@@ -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;
+ }
- // 设置初始值
+ ///
+ /// 窗口加载完成事件
+ ///
+ private void OnWindowLoaded(object? sender, RoutedEventArgs e)
+ {
+ if (_isInitialized) return;
+ _isInitialized = true;
+
+ Console.WriteLine("[ErrorDebugWindow] Window loaded, initializing components...");
+ InitializeComponents();
+
+ // 设置初始值(在视觉树准备好后)
var devModeToggle = this.FindControl("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");
}
///
@@ -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!");
+ }
}
}
diff --git a/LanMountainDesktop.Launcher/Views/ErrorWindow.axaml b/LanMountainDesktop.Launcher/Views/ErrorWindow.axaml
index 70c5de2..8d29ed8 100644
--- a/LanMountainDesktop.Launcher/Views/ErrorWindow.axaml
+++ b/LanMountainDesktop.Launcher/Views/ErrorWindow.axaml
@@ -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">
-
-
-
-
+
+
+
+
+
+
-
+
+ VerticalAlignment="Center"/>
+
+
+
+
+
+
+
+
+
+
+
+
+
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
+
+
+
+
+
+
+
diff --git a/LanMountainDesktop.Launcher/Views/ErrorWindow.axaml.cs b/LanMountainDesktop.Launcher/Views/ErrorWindow.axaml.cs
index 5615c4d..e65134c 100644
--- a/LanMountainDesktop.Launcher/Views/ErrorWindow.axaml.cs
+++ b/LanMountainDesktop.Launcher/Views/ErrorWindow.axaml.cs
@@ -23,17 +23,44 @@ public partial class ErrorWindow : Window
// 先加载保存的状态
_devModeEnabled = LoadDevModeStateInternal();
+ _customHostPath = LoadCustomHostPathInternal();
+ // 延迟到窗口加载完成后再初始化组件,确保视觉树已准备好
+ this.Loaded += OnWindowLoaded;
+ this.Opened += OnWindowOpened;
+ }
+
+ ///
+ /// 窗口加载完成事件 - 视觉树已准备好
+ ///
+ private void OnWindowLoaded(object? sender, RoutedEventArgs e)
+ {
+ Console.WriteLine("[ErrorWindow] Window loaded, initializing components...");
InitializeComponents();
}
+ ///
+ /// 窗口打开事件
+ ///
+ 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("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");
}
///
@@ -63,6 +102,19 @@ public partial class ErrorWindow : Window
}
}
+ ///
+ /// 设置调试模式
+ ///
+ public void SetDebugMode(bool isDebugMode)
+ {
+ _isDebugMode = isDebugMode;
+ var titleText = this.FindControl("TitleText");
+ if (titleText is not null && isDebugMode)
+ {
+ titleText.Text = "[调试模式] 错误页面";
+ }
+ }
+
///
/// 获取用户选择的主程序路径
///
@@ -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");
}
+ ///
+ /// 保存自定义主程序路径(内部方法)
+ ///
+ 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}");
+ }
+ }
+
+ ///
+ /// 加载自定义主程序路径(内部方法)
+ ///
+ 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;
+ }
+
+ ///
+ /// 获取自定义主程序路径文件路径
+ ///
+ private static string GetCustomHostPathFilePath()
+ {
+ var appData = Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData);
+ return Path.Combine(appData, "LanMountainDesktop", ".launcher", "custom-host-path.config");
+ }
+
///
/// 检查是否启用了开发模式(静态方法,启动时调用)
///
@@ -211,6 +334,14 @@ public partial class ErrorWindow : Window
return LoadDevModeStateInternal();
}
+ ///
+ /// 获取保存的自定义主程序路径(静态方法,启动时调用)
+ ///
+ public static string? GetSavedCustomHostPath()
+ {
+ return LoadCustomHostPathInternal();
+ }
+
private void OnRetryClick(object? sender, RoutedEventArgs e)
{
_completionSource.TrySetResult(ErrorWindowResult.Retry);
diff --git a/LanMountainDesktop.Launcher/Views/OobeWindow.axaml b/LanMountainDesktop.Launcher/Views/OobeWindow.axaml
index b877a3c..98cd5a5 100644
--- a/LanMountainDesktop.Launcher/Views/OobeWindow.axaml
+++ b/LanMountainDesktop.Launcher/Views/OobeWindow.axaml
@@ -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">
-
-
-
-
-
-
-
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
-
-
+
+
+
diff --git a/LanMountainDesktop.Launcher/Views/OobeWindow.axaml.cs b/LanMountainDesktop.Launcher/Views/OobeWindow.axaml.cs
index 53f86a9..cb04ee7 100644
--- a/LanMountainDesktop.Launcher/Views/OobeWindow.axaml.cs
+++ b/LanMountainDesktop.Launcher/Views/OobeWindow.axaml.cs
@@ -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;
///
-/// OOBE(首次使用体验)窗口
+/// OOBE(首次使用体验)窗口 - 欢迎页面
///
public partial class OobeWindow : Window
{
private readonly TaskCompletionSource _completionSource = new();
+ private bool _isTransitioning = false;
public OobeWindow()
{
AvaloniaXamlLoader.Load(this);
-
+
+ // 延迟到窗口加载完成后再初始化
+ this.Loaded += OnWindowLoaded;
+ this.Opened += OnWindowOpened;
+ }
+
+ ///
+ /// 窗口加载完成事件
+ ///
+ private void OnWindowLoaded(object? sender, RoutedEventArgs e)
+ {
+ Console.WriteLine("[OobeWindow] Window loaded, initializing components...");
+
var enterButton = this.FindControl
public Task WaitForEnterAsync() => _completionSource.Task;
- private void OnEnterClick(object? sender, RoutedEventArgs e)
+ ///
+ /// 进入按钮点击事件
+ ///
+ 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);
+ }
+ }
+
+ ///
+ /// 播放退出动画
+ ///
+ private async Task PlayExitAnimationAsync()
+ {
+ try
+ {
+ var contentGrid = this.FindControl("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}");
+ }
}
}
diff --git a/LanMountainDesktop.Launcher/Views/SplashWindow.axaml b/LanMountainDesktop.Launcher/Views/SplashWindow.axaml
index d3765dc..05ba1e7 100644
--- a/LanMountainDesktop.Launcher/Views/SplashWindow.axaml
+++ b/LanMountainDesktop.Launcher/Views/SplashWindow.axaml
@@ -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">
-
-
+
+
-
-
-
-
-
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/LanMountainDesktop.Launcher/Views/SplashWindow.axaml.cs b/LanMountainDesktop.Launcher/Views/SplashWindow.axaml.cs
index 662bf2c..c08dbec 100644
--- a/LanMountainDesktop.Launcher/Views/SplashWindow.axaml.cs
+++ b/LanMountainDesktop.Launcher/Views/SplashWindow.axaml.cs
@@ -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;
///
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;
+ }
+
+ ///
+ /// 窗口加载完成事件
+ ///
+ private void OnWindowLoaded(object? sender, RoutedEventArgs e)
+ {
+ Console.WriteLine("[SplashWindow] Window loaded, binding events...");
+
+ // 绑定版本文本点击事件(隐藏功能:点击5次打开开发者界面)
+ var versionTextBorder = this.FindControl("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!");
+ }
+ }
+
+ ///
+ /// 版本文本点击事件 - 连续点击5次打开开发者界面(隐藏功能)
+ ///
+ private void OnVersionTextClick(object? sender, PointerPressedEventArgs e)
+ {
+ if (_isDebugModeOpened) return;
+
+ _versionTextClickCount++;
+ Console.WriteLine($"[SplashWindow] Version text clicked {_versionTextClickCount}/{DebugModeClickThreshold}");
+
+ if (_versionTextClickCount >= DebugModeClickThreshold)
+ {
+ OpenDebugWindow();
+ }
+ }
+
+ ///
+ /// 打开开发者调试窗口
+ ///
+ 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;
+ }
}
///
@@ -22,8 +104,14 @@ public partial class SplashWindow : Window, ISplashStageReporter
{
Dispatcher.UIThread.Post(() =>
{
- var statusText = this.GetControl("StatusText");
- var progressIndicator = this.GetControl("ProgressIndicator");
+ var statusText = this.FindControl("StatusText");
+ var progressIndicator = this.FindControl("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("StatusText");
- var progressIndicator = this.GetControl("ProgressIndicator");
+ var statusText = this.FindControl("StatusText");
+ var progressIndicator = this.FindControl("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("StatusText");
+ var statusText = this.FindControl("StatusText");
+ if (statusText is null)
+ {
+ Console.Error.WriteLine($"[SplashWindow] StatusText not found in UpdateStatus");
+ return;
+ }
statusText.Text = message;
});
}
+ ///
+ /// 报告阶段和进度(0-100)
+ ///
+ public void ReportStage(string stage, int progress)
+ {
+ Dispatcher.UIThread.Post(() =>
+ {
+ var statusText = this.FindControl("StatusText");
+ var progressIndicator = this.FindControl("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);
+ });
+ }
+
+ ///
+ /// 设置版本和开发代号
+ ///
+ public void SetVersionInfo(string version, string codename)
+ {
+ Dispatcher.UIThread.Post(() =>
+ {
+ var versionText = this.FindControl("VersionText");
+ if (versionText is null)
+ {
+ Console.Error.WriteLine($"[SplashWindow] VersionText not found in SetVersionInfo");
+ return;
+ }
+ versionText.Text = $"{version} ({codename})";
+ });
+ }
+
+ ///
+ /// 设置调试模式
+ ///
+ public void SetDebugMode(bool isDebugMode)
+ {
+ Dispatcher.UIThread.Post(() =>
+ {
+ var statusText = this.FindControl("StatusText");
+ if (statusText is null)
+ {
+ Console.Error.WriteLine($"[SplashWindow] StatusText not found in SetDebugMode");
+ return;
+ }
+ if (isDebugMode)
+ {
+ statusText.Text = "[Debug Mode] Splash Preview";
+ }
+ });
+ }
+
///
/// 根据阶段名称解析进度值
///
diff --git a/LanMountainDesktop.Launcher/Views/UpdateWindow.axaml b/LanMountainDesktop.Launcher/Views/UpdateWindow.axaml
new file mode 100644
index 0000000..9d8d2e8
--- /dev/null
+++ b/LanMountainDesktop.Launcher/Views/UpdateWindow.axaml
@@ -0,0 +1,68 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/LanMountainDesktop.Launcher/Views/UpdateWindow.axaml.cs b/LanMountainDesktop.Launcher/Views/UpdateWindow.axaml.cs
new file mode 100644
index 0000000..a7dcc8e
--- /dev/null
+++ b/LanMountainDesktop.Launcher/Views/UpdateWindow.axaml.cs
@@ -0,0 +1,117 @@
+using Avalonia.Controls;
+using Avalonia.Markup.Xaml;
+using Avalonia.Threading;
+
+namespace LanMountainDesktop.Launcher.Views;
+
+///
+/// 更新进度窗口 - 用于 apply-update 命令模式下显示更新/插件升级进度
+///
+public partial class UpdateWindow : Window
+{
+ public UpdateWindow()
+ {
+ AvaloniaXamlLoader.Load(this);
+ }
+
+ ///
+ /// 更新状态和进度
+ ///
+ public void Report(string stage, string message, int progressPercent = -1)
+ {
+ Dispatcher.UIThread.Post(() =>
+ {
+ var statusText = this.FindControl("StatusText");
+ var progressIndicator = this.FindControl("ProgressIndicator");
+ var detailText = this.FindControl("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" => "",
+ _ => ""
+ };
+ });
+ }
+
+ ///
+ /// 显示更新完成状态
+ ///
+ public void ReportComplete(bool success, string? errorMessage = null)
+ {
+ Dispatcher.UIThread.Post(() =>
+ {
+ var statusText = this.FindControl("StatusText");
+ var progressIndicator = this.FindControl("ProgressIndicator");
+ var detailText = this.FindControl("DetailText");
+ var titleText = this.FindControl("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 ?? "更新过程中发生错误";
+ }
+ });
+ }
+
+ ///
+ /// 设置调试模式
+ ///
+ public void SetDebugMode(bool isDebugMode)
+ {
+ Dispatcher.UIThread.Post(() =>
+ {
+ var statusText = this.FindControl("StatusText");
+ var titleText = this.FindControl("TitleText");
+
+ if (statusText is null || titleText is null)
+ {
+ Console.Error.WriteLine($"[UpdateWindow] Controls not found in SetDebugMode");
+ return;
+ }
+
+ if (isDebugMode)
+ {
+ titleText.Text = "[调试模式] 更新页面";
+ statusText.Text = "预览更新进度界面";
+ }
+ });
+ }
+}
diff --git a/LanMountainDesktop.Launcher/app.manifest b/LanMountainDesktop.Launcher/app.manifest
new file mode 100644
index 0000000..87cff0b
--- /dev/null
+++ b/LanMountainDesktop.Launcher/app.manifest
@@ -0,0 +1,25 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/LanMountainDesktop.Shared.Contracts/Launcher/AppVersionInfo.cs b/LanMountainDesktop.Shared.Contracts/Launcher/AppVersionInfo.cs
new file mode 100644
index 0000000..81125c7
--- /dev/null
+++ b/LanMountainDesktop.Shared.Contracts/Launcher/AppVersionInfo.cs
@@ -0,0 +1,22 @@
+namespace LanMountainDesktop.Shared.Contracts.Launcher;
+
+///
+/// 应用版本信息
+///
+public record AppVersionInfo
+{
+ ///
+ /// 版本号,如 "1.0.0"
+ ///
+ public string Version { get; init; } = "0.0.0";
+
+ ///
+ /// 开发代号,如 "Administrate"
+ ///
+ public string Codename { get; init; } = "Unknown";
+
+ ///
+ /// 完整版本字符串,如 "1.0.0 (Administrate)"
+ ///
+ public string FullVersionText => $"{Version} ({Codename})";
+}
diff --git a/LanMountainDesktop.Shared.Contracts/Launcher/LauncherIpc.cs b/LanMountainDesktop.Shared.Contracts/Launcher/LauncherIpc.cs
index 98361ff..dbe7f71 100644
--- a/LanMountainDesktop.Shared.Contracts/Launcher/LauncherIpc.cs
+++ b/LanMountainDesktop.Shared.Contracts/Launcher/LauncherIpc.cs
@@ -81,4 +81,9 @@ public static class LauncherIpcConstants
/// 版本环境变量
///
public const string VersionEnvVar = "LMD_VERSION";
+
+ ///
+ /// 开发代号环境变量
+ ///
+ public const string CodenameEnvVar = "LMD_CODENAME";
}
diff --git a/LanMountainDesktop/App.axaml.cs b/LanMountainDesktop/App.axaml.cs
index bf8e3ef..03d303c 100644
--- a/LanMountainDesktop/App.axaml.cs
+++ b/LanMountainDesktop/App.axaml.cs
@@ -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
}
}
+ ///
+ /// 向 Launcher 报告启动进度(fire-and-forget,不阻塞主流程)
+ ///
+ 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;
}
diff --git a/LanMountainDesktop/LanMountainDesktop.csproj b/LanMountainDesktop/LanMountainDesktop.csproj
index b36d9c5..d97cbe1 100644
--- a/LanMountainDesktop/LanMountainDesktop.csproj
+++ b/LanMountainDesktop/LanMountainDesktop.csproj
@@ -81,4 +81,24 @@
+
+
+
+
+ $(OutDir)version.json
+ $(Version)
+ Administrate
+
+
+
+
+
+
+
+ $(PublishDir)version.json
+ $(Version)
+ Administrate
+
+
+
diff --git a/LanMountainDesktop/Localization/en-US.json b/LanMountainDesktop/Localization/en-US.json
index bc2dabf..a5ef1c6 100644
--- a/LanMountainDesktop/Localization/en-US.json
+++ b/LanMountainDesktop/Localization/en-US.json
@@ -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.",
diff --git a/LanMountainDesktop/Localization/zh-CN.json b/LanMountainDesktop/Localization/zh-CN.json
index 231b612..d3b14fa 100644
--- a/LanMountainDesktop/Localization/zh-CN.json
+++ b/LanMountainDesktop/Localization/zh-CN.json
@@ -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": "下载后未找到安装包文件。",
diff --git a/LanMountainDesktop/Services/Launcher/LauncherIpcClient.cs b/LanMountainDesktop/Services/Launcher/LauncherIpcClient.cs
index d00dddf..04a5fa1 100644
--- a/LanMountainDesktop/Services/Launcher/LauncherIpcClient.cs
+++ b/LanMountainDesktop/Services/Launcher/LauncherIpcClient.cs
@@ -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;
///
/// Launcher IPC 客户端 - 向 Launcher 报告启动进度
+/// 采用持久连接 + 长度前缀协议,在同一连接上可多次发送消息。
+/// 跨平台实现:Windows 使用命名管道,Linux/macOS 使用 Unix 域套接字
///
public class LauncherIpcClient : IDisposable
{
private NamedPipeClientStream? _pipeClient;
private bool _isConnected;
-
+ private readonly object _writeLock = new();
+
+ ///
+ /// 协议:每条消息以 4 字节小端 int32 长度前缀开头,后跟 UTF-8 JSON 正文。
+ ///
+ private const int LengthPrefixSize = 4;
+
///
/// 连接到 Launcher 的 IPC 服务端
///
@@ -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;
}
}
-
+
///
- /// 报告启动进度
+ /// 报告启动进度(在同一连接上可多次调用)
///
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;
}
}
-
+
///
/// 检查是否从 Launcher 启动
///
@@ -74,9 +98,10 @@ public class LauncherIpcClient : IDisposable
return !string.IsNullOrEmpty(
Environment.GetEnvironmentVariable(LauncherIpcConstants.LauncherPidEnvVar));
}
-
+
public void Dispose()
{
+ _isConnected = false;
_pipeClient?.Dispose();
}
}
diff --git a/LanMountainDesktop/Services/Settings/SettingsDomainServices.cs b/LanMountainDesktop/Services/Settings/SettingsDomainServices.cs
index a9d0d12..c2547b3 100644
--- a/LanMountainDesktop/Services/Settings/SettingsDomainServices.cs
+++ b/LanMountainDesktop/Services/Settings/SettingsDomainServices.cs
@@ -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()?
@@ -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()
diff --git a/LanMountainDesktop/Services/UpdateWorkflowService.cs b/LanMountainDesktop/Services/UpdateWorkflowService.cs
index e29a05f..f73a390 100644
--- a/LanMountainDesktop/Services/UpdateWorkflowService.cs
+++ b/LanMountainDesktop/Services/UpdateWorkflowService.cs
@@ -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;
}
+ ///
+ /// 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.
+ ///
+ 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();
diff --git a/LanMountainDesktop/ViewModels/SettingsViewModels.cs b/LanMountainDesktop/ViewModels/SettingsViewModels.cs
index 2f2b124..3088891 100644
--- a/LanMountainDesktop/ViewModels/SettingsViewModels.cs
+++ b/LanMountainDesktop/ViewModels/SettingsViewModels.cs
@@ -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(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(
diff --git a/LanMountainDesktop/Views/SettingsPages/UpdateSettingsPage.axaml b/LanMountainDesktop/Views/SettingsPages/UpdateSettingsPage.axaml
index a7dd544..d855851 100644
--- a/LanMountainDesktop/Views/SettingsPages/UpdateSettingsPage.axaml
+++ b/LanMountainDesktop/Views/SettingsPages/UpdateSettingsPage.axaml
@@ -65,7 +65,7 @@
+
+
+
+
+
+
+
+```
+
+## 故障排除
+
+### 发布失败
+
+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` 中添加 ``
+
+2. **反射失败**
+ - 使用 `[DynamicallyAccessedMembers]` 标记
+
+3. **DllNotFoundException**
+ - 确保所有 native 库都包含在发布中
+
+## 性能对比
+
+| 指标 | JIT | AOT | 提升 |
+|------|-----|-----|------|
+| 启动时间 | 2-3 秒 | 0.5-1 秒 | 2-3x |
+| 内存占用 | 较高 | 较低 | 20-30% |
+| 首次响应 | 慢 | 快 | 显著 |
+
+## 推荐配置
+
+对于 Launcher 项目,推荐使用以下配置:
+
+```xml
+true
+true
+partial
+true
+true
+true
+true
+```
+
+这样发布的结果:
+- ✅ 单文件可执行
+- ✅ 无需 .NET Runtime
+- ✅ 启动速度快
+- ✅ 文件体积合理(8-12 MB)
diff --git a/docs/HOST_DISCOVERY.md b/docs/HOST_DISCOVERY.md
new file mode 100644
index 0000000..47d1e1c
--- /dev/null
+++ b/docs/HOST_DISCOVERY.md
@@ -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
+};
+```
+
+注意:递归搜索可能影响启动性能,建议仅在必要时启用。
diff --git a/docs/LAUNCHER_DISTRIBUTION.md b/docs/LAUNCHER_DISTRIBUTION.md
new file mode 100644
index 0000000..d7cd859
--- /dev/null
+++ b/docs/LAUNCHER_DISTRIBUTION.md
@@ -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. 手动删除配置目录后重新配置
diff --git a/scripts/Generate-VersionFile.ps1 b/scripts/Generate-VersionFile.ps1
new file mode 100644
index 0000000..5574243
--- /dev/null
+++ b/scripts/Generate-VersionFile.ps1
@@ -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
diff --git a/scripts/Publish-AOT.ps1 b/scripts/Publish-AOT.ps1
new file mode 100644
index 0000000..c712b7c
--- /dev/null
+++ b/scripts/Publish-AOT.ps1
@@ -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
+}
diff --git a/test-launcher.ps1 b/test-launcher.ps1
new file mode 100644
index 0000000..71b26cc
--- /dev/null
+++ b/test-launcher.ps1
@@ -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