From 81ee19f360b7a3e4cb6eb8b76e8ea17b55a0e93f Mon Sep 17 00:00:00 2001 From: lincube Date: Fri, 17 Apr 2026 15:16:01 +0800 Subject: [PATCH] =?UTF-8?q?feat.=E5=B0=9D=E8=AF=95=E5=BC=84=E4=BA=86AOT?= =?UTF-8?q?=E7=9A=84=E5=90=AF=E5=8A=A8=E5=99=A8=E3=80=82?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .github/workflows/release.yml | 95 ++- LanMountainDesktop.Launcher/App.axaml.cs | 373 ++++++++++- LanMountainDesktop.Launcher/Assets/logo.ico | Bin 0 -> 11226 bytes LanMountainDesktop.Launcher/CommandContext.cs | 18 +- .../LanMountainDesktop.Launcher.AOT.props | 62 ++ ...nMountainDesktop.Launcher.SingleFile.props | 29 + .../LanMountainDesktop.Launcher.csproj | 11 +- LanMountainDesktop.Launcher/Program.cs | 159 +---- .../Services/Commands.cs | 19 +- .../Services/DeferredSplashStageReporter.cs | 8 + .../Services/DeploymentLocator.cs | 79 ++- .../Services/FlexibleHostLocator.cs | 612 ++++++++++++++++++ .../Services/HostDiscoveryOptions.cs | 47 ++ .../Services/ISplashStageReporter.cs | 5 + .../Services/Ipc/LauncherIpcServer.cs | 181 ++++-- .../Services/LauncherFlowCoordinator.cs | 259 ++++++-- .../Services/OobeStateService.cs | 7 +- .../ViewModels/DevDebugWindowViewModel.cs | 263 ++++++++ .../ViewModels/RelayCommand.cs | 67 ++ .../Views/DevDebugWindow.axaml | 182 ++++++ .../Views/DevDebugWindow.axaml.cs | 196 ++++++ .../Views/ErrorDebugWindow.axaml | 3 +- .../Views/ErrorDebugWindow.axaml.cs | 52 +- .../Views/ErrorWindow.axaml | 134 ++-- .../Views/ErrorWindow.axaml.cs | 133 +++- .../Views/OobeWindow.axaml | 91 ++- .../Views/OobeWindow.axaml.cs | 171 ++++- .../Views/SplashWindow.axaml | 100 ++- .../Views/SplashWindow.axaml.cs | 168 ++++- .../Views/UpdateWindow.axaml | 68 ++ .../Views/UpdateWindow.axaml.cs | 117 ++++ LanMountainDesktop.Launcher/app.manifest | 25 + .../Launcher/AppVersionInfo.cs | 22 + .../Launcher/LauncherIpc.cs | 5 + LanMountainDesktop/App.axaml.cs | 39 +- LanMountainDesktop/LanMountainDesktop.csproj | 20 + LanMountainDesktop/Localization/en-US.json | 6 + LanMountainDesktop/Localization/zh-CN.json | 6 + .../Services/Launcher/LauncherIpcClient.cs | 45 +- .../Settings/SettingsDomainServices.cs | 20 +- .../Services/UpdateWorkflowService.cs | 61 +- .../ViewModels/SettingsViewModels.cs | 64 +- .../SettingsPages/UpdateSettingsPage.axaml | 12 +- docs/AOT_PUBLISH.md | 178 +++++ docs/HOST_DISCOVERY.md | 78 +++ docs/LAUNCHER_DISTRIBUTION.md | 129 ++++ scripts/Generate-VersionFile.ps1 | 28 + scripts/Publish-AOT.ps1 | 137 ++++ test-launcher.ps1 | 59 ++ 49 files changed, 4175 insertions(+), 468 deletions(-) create mode 100644 LanMountainDesktop.Launcher/Assets/logo.ico create mode 100644 LanMountainDesktop.Launcher/LanMountainDesktop.Launcher.AOT.props create mode 100644 LanMountainDesktop.Launcher/LanMountainDesktop.Launcher.SingleFile.props create mode 100644 LanMountainDesktop.Launcher/Services/FlexibleHostLocator.cs create mode 100644 LanMountainDesktop.Launcher/Services/HostDiscoveryOptions.cs create mode 100644 LanMountainDesktop.Launcher/ViewModels/DevDebugWindowViewModel.cs create mode 100644 LanMountainDesktop.Launcher/ViewModels/RelayCommand.cs create mode 100644 LanMountainDesktop.Launcher/Views/DevDebugWindow.axaml create mode 100644 LanMountainDesktop.Launcher/Views/DevDebugWindow.axaml.cs create mode 100644 LanMountainDesktop.Launcher/Views/UpdateWindow.axaml create mode 100644 LanMountainDesktop.Launcher/Views/UpdateWindow.axaml.cs create mode 100644 LanMountainDesktop.Launcher/app.manifest create mode 100644 LanMountainDesktop.Shared.Contracts/Launcher/AppVersionInfo.cs create mode 100644 docs/AOT_PUBLISH.md create mode 100644 docs/HOST_DISCOVERY.md create mode 100644 docs/LAUNCHER_DISTRIBUTION.md create mode 100644 scripts/Generate-VersionFile.ps1 create mode 100644 scripts/Publish-AOT.ps1 create mode 100644 test-launcher.ps1 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 0000000000000000000000000000000000000000..aa1524dc89687495338fe35fa0ce52a9c40000ef GIT binary patch literal 11226 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 k^E1O4 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/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