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/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