From a26b6faace509f2ff8806e95fe5891ce4b325fc4 Mon Sep 17 00:00:00 2001 From: lincube Date: Thu, 28 May 2026 11:03:49 +0800 Subject: [PATCH] refactor(launcher): replace LauncherFlowCoordinator with LaunchPipeline and slim App shell Co-authored-by: Cursor --- LanMountainDesktop.Launcher/App.axaml.cs | 841 +----------------- .../EntryHandlers/LaunchEntryHandlers.cs | 90 ++ .../EntryHandlers/PreviewEntryHandler.cs | 136 +++ .../Shell/LauncherCompositionRoot.cs | 620 +++++++++++++ .../Shell/LauncherOrchestrator.cs | 282 ++++++ .../Startup/ExistingHostProbe.cs | 162 ++++ .../Startup/HostLaunchModels.cs | 77 ++ .../Startup/HostLaunchService.cs | 319 +++++++ .../Startup/LaunchAttemptDetails.cs | 49 + .../Startup/LaunchPipeline.cs | 191 ++++ .../Startup/LaunchUiPresenter.cs | 174 ++++ .../Startup/Phases/ApplyPendingUpdatePhase.cs | 27 + .../Startup/Phases/CleanupDeploymentsPhase.cs | 17 + .../Startup/Phases/ExistingHostProbePhase.cs | 71 ++ .../Startup/Phases/LaunchHostPhase.cs | 173 ++++ .../Startup/Phases/MonitorStartupPhase.cs | 68 ++ .../Startup/Phases/OobeGatePhase.cs | 24 + .../LauncherAirAppLifecycleServiceTests.cs | 7 +- .../LauncherGlobalUsings.cs | 3 +- 19 files changed, 2530 insertions(+), 801 deletions(-) create mode 100644 LanMountainDesktop.Launcher/Shell/EntryHandlers/LaunchEntryHandlers.cs create mode 100644 LanMountainDesktop.Launcher/Shell/EntryHandlers/PreviewEntryHandler.cs create mode 100644 LanMountainDesktop.Launcher/Shell/LauncherCompositionRoot.cs create mode 100644 LanMountainDesktop.Launcher/Shell/LauncherOrchestrator.cs create mode 100644 LanMountainDesktop.Launcher/Startup/ExistingHostProbe.cs create mode 100644 LanMountainDesktop.Launcher/Startup/HostLaunchModels.cs create mode 100644 LanMountainDesktop.Launcher/Startup/HostLaunchService.cs create mode 100644 LanMountainDesktop.Launcher/Startup/LaunchAttemptDetails.cs create mode 100644 LanMountainDesktop.Launcher/Startup/LaunchPipeline.cs create mode 100644 LanMountainDesktop.Launcher/Startup/LaunchUiPresenter.cs create mode 100644 LanMountainDesktop.Launcher/Startup/Phases/ApplyPendingUpdatePhase.cs create mode 100644 LanMountainDesktop.Launcher/Startup/Phases/CleanupDeploymentsPhase.cs create mode 100644 LanMountainDesktop.Launcher/Startup/Phases/ExistingHostProbePhase.cs create mode 100644 LanMountainDesktop.Launcher/Startup/Phases/LaunchHostPhase.cs create mode 100644 LanMountainDesktop.Launcher/Startup/Phases/MonitorStartupPhase.cs create mode 100644 LanMountainDesktop.Launcher/Startup/Phases/OobeGatePhase.cs diff --git a/LanMountainDesktop.Launcher/App.axaml.cs b/LanMountainDesktop.Launcher/App.axaml.cs index b0c2d65..7bbca5d 100644 --- a/LanMountainDesktop.Launcher/App.axaml.cs +++ b/LanMountainDesktop.Launcher/App.axaml.cs @@ -1,16 +1,11 @@ -using System.Diagnostics; using Avalonia; using Avalonia.Controls; using Avalonia.Controls.ApplicationLifetimes; using Avalonia.Markup.Xaml; -using Avalonia.Threading; using LanMountainDesktop.Launcher.Models; -using LanMountainDesktop.Launcher.Resources; -using LanMountainDesktop.Launcher.Services; +using LanMountainDesktop.Launcher.Shell; +using LanMountainDesktop.Launcher.Shell.EntryHandlers; using LanMountainDesktop.Launcher.Views; -using LanMountainDesktop.Shared.Contracts.Launcher; -using LanMountainDesktop.Shared.IPC; -using LanMountainDesktop.Shared.IPC.Abstractions.Services; namespace LanMountainDesktop.Launcher; @@ -44,804 +39,56 @@ public partial class App : Application return; } - if (ApplicationLifetime is IClassicDesktopStyleApplicationLifetime desktop) + if (ApplicationLifetime is not IClassicDesktopStyleApplicationLifetime desktop) { - desktop.ShutdownMode = ShutdownMode.OnExplicitShutdown; + base.OnFrameworkInitializationCompleted(); + return; + } - var context = LauncherRuntimeContext.Current; - var execution = LauncherExecutionContext.Capture(); - Logger.Info( - $"Framework initialization completed. Command='{context.Command}'; IsPreview={context.IsPreviewCommand}; " + - $"IsDebugMode={context.IsDebugMode}; LaunchSource='{context.LaunchSource}'; " + - $"IsElevated={execution.IsElevated}; UserSid='{execution.UserSid ?? string.Empty}'."); + desktop.ShutdownMode = ShutdownMode.OnExplicitShutdown; - if (HandlePreviewCommand(context, desktop)) - { - base.OnFrameworkInitializationCompleted(); - return; - } + var context = LauncherRuntimeContext.Current; + var execution = LauncherExecutionContext.Capture(); + Logger.Info( + $"Framework initialization completed. Command='{context.Command}'; IsPreview={context.IsPreviewCommand}; " + + $"IsDebugMode={context.IsDebugMode}; LaunchSource='{context.LaunchSource}'; " + + $"IsElevated={execution.IsElevated}; UserSid='{execution.UserSid ?? string.Empty}'."); - if (context.IsAirAppBrokerCommand) - { - _ = RunAirAppBrokerAsync(desktop, context); - base.OnFrameworkInitializationCompleted(); - return; - } + if (PreviewEntryHandler.TryHandle(context, desktop)) + { + base.OnFrameworkInitializationCompleted(); + return; + } - // ??模式?只?示 DevDebugWindow?不走正常启?流? - // 避?启?主?序? Launcher ??????导????????? UI - if (context.IsDebugMode && !context.IsPreviewCommand && - !string.Equals(context.Command, "apply-update", StringComparison.OrdinalIgnoreCase)) - { - Logger.Info("Debug mode active ?? showing DevDebugWindow instead of normal launch flow."); - var devDebugWindow = new DevDebugWindow(); - devDebugWindow.Show(); - base.OnFrameworkInitializationCompleted(); - return; - } + if (context.IsAirAppBrokerCommand) + { + _ = AirAppBrokerEntryHandler.RunAsync(desktop, context); + base.OnFrameworkInitializationCompleted(); + return; + } - if (string.Equals(context.Command, "apply-update", StringComparison.OrdinalIgnoreCase)) - { - var updateWindow = new UpdateWindow(); - updateWindow.Show(); - _ = RunApplyUpdateWithWindowAsync(desktop, context, updateWindow); - } - else - { - var splashWindow = CreateSplashWindow(); - splashWindow.Show(); - _ = RunCoordinatorWithSplashAsync(desktop, context, splashWindow); - } + if (context.IsDebugMode && !context.IsPreviewCommand && + !string.Equals(context.Command, "apply-update", StringComparison.OrdinalIgnoreCase)) + { + Logger.Info("Debug mode active; showing DevDebugWindow instead of normal launch flow."); + new DevDebugWindow().Show(); + base.OnFrameworkInitializationCompleted(); + return; + } + + if (string.Equals(context.Command, "apply-update", StringComparison.OrdinalIgnoreCase)) + { + var updateWindow = new UpdateWindow(); + updateWindow.Show(); + _ = ApplyUpdateEntryHandler.RunAsync(desktop, context, updateWindow); + } + else + { + var splashWindow = LaunchEntryHandler.CreateSplashWindow(); + splashWindow.Show(); + _ = LauncherCompositionRoot.RunOrchestratorWithSplashAsync(desktop, context, splashWindow); } base.OnFrameworkInitializationCompleted(); } - - private static async Task RunAirAppBrokerAsync( - IClassicDesktopStyleApplicationLifetime desktop, - CommandContext context) - { - var appRoot = Commands.ResolveAppRoot(context); - var requesterPid = context.GetIntOption("requester-pid", 0); - var dataLocationResolver = new DataLocationResolver(appRoot); - Logger.Info($"Air APP broker starting. AppRoot='{appRoot}'; RequesterPid={requesterPid}."); - - using var airAppIpcHost = new LauncherAirAppLifecycleIpcHost( - new LauncherAirAppLifecycleService( - new AirAppProcessStarter( - new AirAppHostLocator(), - () => appRoot, - () => null, - () => dataLocationResolver.ResolveDataRoot()))); - airAppIpcHost.Start(); - - await WaitForAirAppBrokerExitAsync(requesterPid, airAppIpcHost.LifecycleService).ConfigureAwait(false); - - Logger.Info("Air APP broker exiting."); - await Dispatcher.UIThread.InvokeAsync(() => desktop.Shutdown(0), DispatcherPriority.Background); - } - - internal static async Task WaitForAirAppBrokerExitAsync( - int requesterPid, - LauncherAirAppLifecycleService airAppLifecycleService) - { - while (ShouldKeepAirAppBrokerAlive(requesterPid, airAppLifecycleService)) - { - await Task.Delay(TimeSpan.FromSeconds(2)).ConfigureAwait(false); - } - } - - internal static bool ShouldKeepAirAppBrokerAlive( - int requesterPid, - LauncherAirAppLifecycleService airAppLifecycleService) - { - return TryGetLiveProcess(requesterPid) || airAppLifecycleService.HasLiveAirApps(); - } - - private bool HandlePreviewCommand(CommandContext context, IClassicDesktopStyleApplicationLifetime desktop) - { - switch (context.Command.ToLowerInvariant()) - { - case "preview-splash": - { - Logger.Info("Preview command: splash."); - var splashWindow = CreateSplashWindow(); - splashWindow.SetDebugMode(true); - splashWindow.Show(); - _ = SimulateSplashPreviewAsync(desktop, splashWindow); - return true; - } - case "preview-error": - { - Logger.Info("Preview command: error."); - var errorWindow = new ErrorWindow(); - errorWindow.SetErrorMessage(Strings.Preview_ErrorMessage); - errorWindow.Show(); - _ = WaitForWindowCloseAsync(desktop, errorWindow); - return true; - } - case "preview-multi-instance": - { - Logger.Info("Preview command: multi-instance prompt."); - var promptWindow = new MultiInstancePromptWindow(); - promptWindow.SetDetails(Environment.ProcessId, "ForegroundDesktop"); - promptWindow.Show(); - _ = WaitForWindowCloseAsync(desktop, promptWindow); - return true; - } - case "preview-update": - { - Logger.Info("Preview command: update."); - var updateWindow = new UpdateWindow(); - updateWindow.SetDebugMode(true); - updateWindow.Show(); - _ = SimulateUpdatePreviewAsync(desktop, updateWindow); - return true; - } - case "preview-oobe": - { - Logger.Info("Preview command: oobe."); - var oobeWindow = new OobeWindow(); - oobeWindow.Show(); - _ = SimulateOobePreviewAsync(desktop, oobeWindow); - return true; - } - case "preview-debug": - { - Logger.Info("Preview command: debug window."); - var devDebugWindow = new DevDebugWindow(); - devDebugWindow.Show(); - return true; - } - default: - return false; - } - } - - private static SplashWindow CreateSplashWindow() - { - var window = new SplashWindow(); - TrySetSplashVersionInfo(window, LauncherRuntimeContext.Current); - return window; - } - - private static void TrySetSplashVersionInfo(SplashWindow window, CommandContext context) - { - try - { - var appRoot = Commands.ResolveAppRoot(context); - var versionInfo = new DeploymentLocator(appRoot).GetVersionInfo(); - window.SetVersionInfo(versionInfo.Version, versionInfo.Codename); - } - catch (Exception ex) - { - Logger.Warn($"Failed to set splash version info before coordinator start: {ex.Message}"); - } - } - - private async Task SimulateSplashPreviewAsync(IClassicDesktopStyleApplicationLifetime desktop, SplashWindow window) - { - var stages = new[] { "initializing", "update", "plugins", "launch", "ready" }; - var messages = new[] { Strings.Preview_SplashInitializing, Strings.Preview_SplashCheckingUpdates, Strings.Preview_SplashCheckingPlugins, Strings.Preview_SplashLaunchingHost, Strings.Preview_SplashReady }; - var reporter = (ISplashStageReporter)window; - - for (var i = 0; i < stages.Length; i++) - { - reporter.Report(stages[i], messages[i]); - await Task.Delay(800).ConfigureAwait(false); - } - - await Task.Delay(5000).ConfigureAwait(false); - await Dispatcher.UIThread.InvokeAsync(() => desktop.Shutdown(0)); - } - - private async Task SimulateUpdatePreviewAsync(IClassicDesktopStyleApplicationLifetime desktop, UpdateWindow window) - { - var stages = new[] { "verify", "extract", "apply", "plugins", "cleanup" }; - - for (var i = 0; i < stages.Length; i++) - { - window.Report(stages[i], string.Format(Strings.Preview_UpdateProcessing, stages[i]), (i + 1) * 20); - await Task.Delay(600).ConfigureAwait(false); - } - - window.ReportComplete(true, null); - await Task.Delay(3000).ConfigureAwait(false); - await Dispatcher.UIThread.InvokeAsync(() => desktop.Shutdown(0)); - } - - private async Task SimulateOobePreviewAsync(IClassicDesktopStyleApplicationLifetime desktop, OobeWindow window) - { - try - { - await window.WaitForEnterAsync().ConfigureAwait(false); - Logger.Info("OOBE preview completed by user."); - } - catch (Exception ex) - { - Logger.Error("OOBE preview failed.", ex); - } - - await Dispatcher.UIThread.InvokeAsync(() => desktop.Shutdown(0)); - } - - private async Task WaitForWindowCloseAsync(IClassicDesktopStyleApplicationLifetime desktop, Window window) - { - var tcs = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); - window.Closed += (_, _) => tcs.TrySetResult(); - await tcs.Task.ConfigureAwait(false); - await Dispatcher.UIThread.InvokeAsync(() => desktop.Shutdown(0)); - } - - private static async Task RunCoordinatorWithSplashAsync( - IClassicDesktopStyleApplicationLifetime desktop, - CommandContext context, - SplashWindow splashWindow) - { - LauncherResult result; - SplashWindow? currentSplashWindow = splashWindow; - var appRoot = Commands.ResolveAppRoot(context); - var dataLocationResolver = new DataLocationResolver(appRoot); - var startupAttemptRegistry = new StartupAttemptRegistry(); - var coordinatorPipeName = LauncherCoordinatorIpcServer.CreatePipeName(); - var successPolicy = LauncherFlowCoordinator.ResolveSuccessPolicyKey(context); - if (!startupAttemptRegistry.TryReserveCoordinator( - context.LaunchSource, - successPolicy, - coordinatorPipeName, - out var reservedAttempt, - out var activeCoordinatorAttempt)) - { - result = await AttachToExistingCoordinatorAsync( - context, - currentSplashWindow, - activeCoordinatorAttempt).ConfigureAwait(false); - - Logger.Info($"Secondary launcher completed. Success={result.Success}; Code='{result.Code}'."); - await WriteLauncherResultAsync(context, result).ConfigureAwait(false); - - Environment.ExitCode = result.Success ? 0 : 1; - await Dispatcher.UIThread.InvokeAsync(() => desktop.Shutdown(Environment.ExitCode), DispatcherPriority.Background); - return; - } - - using var airAppIpcHost = new LauncherAirAppLifecycleIpcHost( - new LauncherAirAppLifecycleService( - new AirAppProcessStarter( - new AirAppHostLocator(), - () => appRoot, - () => null, - () => dataLocationResolver.ResolveDataRoot()))); - airAppIpcHost.Start(); - - using var coordinatorServer = new LauncherCoordinatorIpcServer( - coordinatorPipeName, - BuildCoordinatorStatusFromAttempt(reservedAttempt), - HandleCoordinatorRequestAsync, - startupAttemptRegistry.UpdateOwnedCoordinatorHeartbeat); - coordinatorServer.Start(); - - while (true) - { - try - { - Logger.Info( - $"Coordinator start. Command='{context.Command}'; AppRoot='{appRoot}'; " + - $"IsDebugMode={context.IsDebugMode}; LaunchSource='{context.LaunchSource}'; " + - $"ResultPath='{context.GetOption("result") ?? ""}'."); - - var deploymentLocator = new DeploymentLocator(appRoot); - var coordinator = new LauncherFlowCoordinator( - context, - deploymentLocator, - new OobeStateService(appRoot), - new UpdateEngineService(deploymentLocator), - new PluginInstallerService(), - startupAttemptRegistry, - coordinatorServer); - - result = await coordinator.RunAsync(currentSplashWindow).ConfigureAwait(false); - } - catch (Exception ex) - { - Logger.Error("Coordinator threw an unhandled exception.", ex); - result = new LauncherResult - { - Success = false, - Stage = "launch", - Code = "exception", - Message = $"Launcher failed: {ex.Message}", - ErrorMessage = ex.ToString() - }; - } - - if (result.Success || - result.Code == "host_not_found" || - (!string.Equals(result.Stage, "launch", StringComparison.OrdinalIgnoreCase) && - !string.Equals(result.Stage, "launchHost", StringComparison.OrdinalIgnoreCase))) - { - break; - } - - var failureAction = await ShowFailureWindowAsync(result).ConfigureAwait(false); - if (failureAction == ErrorWindowResult.Exit) - { - break; - } - - if (failureAction == ErrorWindowResult.ActivateExisting && - await TryActivateExistingInstanceAsync().ConfigureAwait(false)) - { - result = new LauncherResult - { - Success = true, - Stage = "launch", - Code = "activation_requested", - Message = "Launcher activated the existing desktop instance.", - Details = result.Details - }; - break; - } - - currentSplashWindow = CreateSplashWindow(); - currentSplashWindow.Show(); - } - - Logger.Info($"Coordinator completed. Success={result.Success}; Stage='{result.Stage}'; Code='{result.Code}'."); - await WriteLauncherResultAsync(context, result).ConfigureAwait(false); - - Environment.ExitCode = result.Success ? 0 : 1; - if (result.Success) - { - var hostPid = ResolveManagedHostPid(result, startupAttemptRegistry.GetOwnedAttempt()?.HostPid ?? 0); - await WaitForManagedProcessesToExitAsync(hostPid, airAppIpcHost.LifecycleService).ConfigureAwait(false); - } - - await Dispatcher.UIThread.InvokeAsync(() => desktop.Shutdown(Environment.ExitCode), DispatcherPriority.Background); - } - - private static int ResolveManagedHostPid(LauncherResult result, int fallbackHostPid) - { - if (result.Details.TryGetValue("hostPid", out var hostPidText) && - int.TryParse(hostPidText, out var hostPid)) - { - return hostPid; - } - - if (result.Details.TryGetValue("existingHostPid", out var existingHostPidText) && - int.TryParse(existingHostPidText, out var existingHostPid)) - { - return existingHostPid; - } - - return fallbackHostPid; - } - - private static async Task WaitForManagedProcessesToExitAsync( - int hostPid, - LauncherAirAppLifecycleService airAppLifecycleService) - { - Logger.Info($"Launcher entering managed background lifetime. HostPid={hostPid}."); - while (TryGetLiveProcess(hostPid) || airAppLifecycleService.HasLiveAirApps()) - { - await Task.Delay(TimeSpan.FromSeconds(2)).ConfigureAwait(false); - } - - Logger.Info("Launcher managed background lifetime completed; no host or Air APP process remains."); - } - - private static async Task AttachToExistingCoordinatorAsync( - CommandContext context, - SplashWindow? splashWindow, - StartupAttemptRecord? activeCoordinatorAttempt) - { - var reporter = splashWindow as ISplashStageReporter; - reporter?.Report("activation", Strings.Preview_ActivationConnecting); - - if (activeCoordinatorAttempt is not null && - !string.IsNullOrWhiteSpace(activeCoordinatorAttempt.CoordinatorPipeName)) - { - var command = string.Equals(context.LaunchSource, "restart", StringComparison.OrdinalIgnoreCase) - ? LauncherCoordinatorCommands.Attach - : LauncherCoordinatorCommands.ActivateDesktop; - var request = new LauncherCoordinatorRequest - { - Command = command, - LaunchSource = context.LaunchSource, - SuccessPolicy = LauncherFlowCoordinator.ResolveSuccessPolicyKey(context) - }; - - var response = await new LauncherCoordinatorIpcClient() - .SendAsync(activeCoordinatorAttempt.CoordinatorPipeName, request, TimeSpan.FromSeconds(2)) - .ConfigureAwait(false); - - if (response is not null) - { - reporter?.Report("activation", response.Message); - await DismissSplashIfNeededAsync(splashWindow).ConfigureAwait(false); - var success = response.Accepted || - IsRecoverableActivationFailure(response.ActivationResult, response.Status); - return new LauncherResult - { - Success = success, - Stage = "launch", - Code = success && !response.Accepted ? "attached_to_launcher_coordinator" : response.Code, - Message = success && !response.Accepted - ? "Attached to the active Launcher coordinator; desktop startup is still in progress." - : response.Message, - Details = BuildCoordinatorResultDetails(response.Status, response.ActivationResult) - }; - } - } - - var activation = await TryActivateExistingInstanceWithStatusAsync(TimeSpan.FromSeconds(2)).ConfigureAwait(false); - if (activation is not null) - { - reporter?.Report("activation", activation.Message); - await DismissSplashIfNeededAsync(splashWindow).ConfigureAwait(false); - var success = activation.Accepted || IsRecoverableActivationFailure(activation, null); - return new LauncherResult - { - Success = success, - Stage = "launch", - Code = activation.Accepted - ? "existing_host_activated" - : success - ? "existing_host_startup_pending" - : "existing_host_activation_failed", - Message = success && !activation.Accepted - ? "Existing desktop process is still starting; Launcher attached without starting another process." - : activation.Message, - Details = BuildCoordinatorResultDetails(null, activation) - }; - } - - await DismissSplashIfNeededAsync(splashWindow).ConfigureAwait(false); - return new LauncherResult - { - Success = false, - Stage = "launch", - Code = "launcher_coordinator_unavailable", - Message = "Another Launcher is coordinating startup, but it did not respond in time.", - Details = new Dictionary(StringComparer.OrdinalIgnoreCase) - { - ["activeCoordinatorPid"] = activeCoordinatorAttempt?.CoordinatorPid.ToString() ?? string.Empty, - ["activeCoordinatorPipeName"] = activeCoordinatorAttempt?.CoordinatorPipeName ?? string.Empty, - ["activeAttemptId"] = activeCoordinatorAttempt?.AttemptId ?? string.Empty, - ["activeHostPid"] = activeCoordinatorAttempt?.HostPid.ToString() ?? string.Empty - } - }; - } - - private static async Task HandleCoordinatorRequestAsync( - LauncherCoordinatorRequest request, - LauncherCoordinatorStatus status) - { - if (string.Equals(request.Command, LauncherCoordinatorCommands.ActivateDesktop, StringComparison.OrdinalIgnoreCase)) - { - var activation = await TryActivateExistingInstanceWithStatusAsync(TimeSpan.FromSeconds(2)).ConfigureAwait(false); - if (activation is not null) - { - if (!activation.Accepted && IsRecoverableActivationFailure(activation, status)) - { - return new LauncherCoordinatorResponse - { - Accepted = true, - Code = "attached_to_launcher_coordinator", - Message = "Attached to the active Launcher coordinator; desktop startup is still in progress.", - Status = status, - ActivationResult = activation - }; - } - - return new LauncherCoordinatorResponse - { - Accepted = activation.Accepted, - Code = activation.Accepted ? "existing_host_activated" : "existing_host_activation_failed", - Message = activation.Message, - Status = status, - ActivationResult = activation - }; - } - - return new LauncherCoordinatorResponse - { - Accepted = true, - Code = "attached_to_launcher_coordinator", - Message = "Attached to the active Launcher coordinator; desktop startup is still in progress.", - Status = status - }; - } - - return new LauncherCoordinatorResponse - { - Accepted = true, - Code = "attached_to_launcher_coordinator", - Message = "Attached to the active Launcher coordinator.", - Status = status - }; - } - - private static LauncherCoordinatorStatus BuildCoordinatorStatusFromAttempt(StartupAttemptRecord attempt) - { - return new LauncherCoordinatorStatus - { - AttemptId = attempt.AttemptId, - CoordinatorPid = Environment.ProcessId, - HostPid = attempt.HostPid, - HostProcessAlive = TryGetLiveProcess(attempt.HostPid), - LaunchSource = attempt.LaunchSource, - SuccessPolicy = attempt.SuccessPolicy, - LastObservedStage = attempt.LastObservedStage, - LastObservedMessage = attempt.LastObservedMessage, - PublicIpcConnected = attempt.PublicIpcConnected || attempt.IpcConnected, - State = attempt.State.ToString(), - SoftTimeoutShown = attempt.State is StartupAttemptState.SoftTimeout or StartupAttemptState.DetachedWaiting, - Completed = attempt.State is StartupAttemptState.Succeeded or StartupAttemptState.Failed, - Succeeded = attempt.State == StartupAttemptState.Succeeded, - UpdatedAtUtc = attempt.UpdatedAtUtc - }; - } - - private static bool IsRecoverableActivationFailure( - PublicShellActivationResult? activation, - LauncherCoordinatorStatus? status) - { - if (activation is { Accepted: true }) - { - return false; - } - - if (status is { Completed: false, HostProcessAlive: true }) - { - return true; - } - - var shellStatus = activation?.Status; - if (shellStatus is null || !shellStatus.PublicIpcReady) - { - return false; - } - - return !shellStatus.MainWindowOpened || - !shellStatus.DesktopVisible || - string.Equals(activation?.Code, "shell_not_ready", StringComparison.OrdinalIgnoreCase) || - string.Equals(activation?.Code, "startup_pending", StringComparison.OrdinalIgnoreCase); - } - - private static Dictionary BuildCoordinatorResultDetails( - LauncherCoordinatorStatus? status, - PublicShellActivationResult? activation) - { - var details = new Dictionary(StringComparer.OrdinalIgnoreCase) - { - ["coordinatorPid"] = status?.CoordinatorPid.ToString() ?? string.Empty, - ["coordinatorAttemptId"] = status?.AttemptId ?? string.Empty, - ["hostPid"] = status?.HostPid.ToString() ?? activation?.Status.ProcessId.ToString() ?? string.Empty, - ["hostProcessAlive"] = status?.HostProcessAlive.ToString() ?? string.Empty, - ["publicIpcConnected"] = (status?.PublicIpcConnected ?? activation is not null).ToString(), - ["startupStage"] = status?.LastObservedStage.ToString() ?? string.Empty, - ["startupState"] = status?.State ?? string.Empty, - ["activationAccepted"] = activation?.Accepted.ToString() ?? string.Empty, - ["shellState"] = activation?.Status.ShellState ?? status?.ShellStatus?.ShellState ?? string.Empty, - ["trayState"] = activation?.Status.Tray.State ?? status?.ShellStatus?.Tray.State ?? string.Empty, - ["taskbarUsable"] = activation?.Status.Taskbar.IsUsable.ToString() ?? status?.ShellStatus?.Taskbar.IsUsable.ToString() ?? string.Empty - }; - - return details; - } - - private static async Task DismissSplashIfNeededAsync(SplashWindow? splashWindow) - { - if (splashWindow is null) - { - return; - } - - try - { - await splashWindow.DismissAsync().ConfigureAwait(false); - } - catch (Exception ex) - { - Logger.Warn($"Failed to dismiss splash after coordinator attach: {ex.Message}"); - } - } - - private static async Task WriteLauncherResultAsync(CommandContext context, LauncherResult result) - { - var resultPath = context.GetOption("result"); - if (string.IsNullOrWhiteSpace(resultPath)) - { - return; - } - - try - { - await Commands.WriteResultIfNeededAsync(resultPath, result).ConfigureAwait(false); - Logger.Info($"Launcher result written to '{Path.GetFullPath(resultPath)}'."); - } - catch (Exception ex) - { - Logger.Error($"Failed to write launcher result to '{resultPath}'.", ex); - } - } - - private static async Task ShowFailureWindowAsync(LauncherResult result) - { - ErrorWindow? errorWindow = null; - var hostProcessAlive = result.Details.TryGetValue("hostProcessAlive", out var hostProcessAliveText) && - bool.TryParse(hostProcessAliveText, out var hostProcessAliveValue) && - hostProcessAliveValue; - var hostPid = result.Details.TryGetValue("hostPid", out var hostPidText) && - int.TryParse(hostPidText, out var parsedPid) - ? parsedPid - : (int?)null; - - await Dispatcher.UIThread.InvokeAsync(() => - { - try - { - errorWindow = new ErrorWindow(); - if (hostProcessAlive) - { - errorWindow.ConfigureForRunningHostFailure(hostPid); - } - else - { - errorWindow.ConfigureForGenericFailure(allowRetry: true); - } - - errorWindow.SetErrorMessage( - $"Failed to start LanMountainDesktop.\n\nStage: {result.Stage}\nCode: {result.Code}\n\n{result.Message}"); - errorWindow.Show(); - } - catch (Exception ex) - { - Logger.Error("Failed to show launcher failure window.", ex); - } - }); - - if (errorWindow is null) - { - return ErrorWindowResult.Exit; - } - - try - { - return await errorWindow.WaitForChoiceAsync().ConfigureAwait(false); - } - catch (Exception ex) - { - Logger.Error("Failure window closed unexpectedly.", ex); - return ErrorWindowResult.Exit; - } - } - - private static async Task TryActivateExistingInstanceAsync() - { - var activation = await TryActivateExistingInstanceWithStatusAsync(TimeSpan.FromSeconds(5)).ConfigureAwait(false); - return activation?.Accepted == true; - } - - private static async Task TryActivateExistingInstanceWithStatusAsync(TimeSpan timeout) - { - try - { - using var ipcClient = new LanMountainDesktopIpcClient(); - var connectTask = ipcClient.ConnectAsync(); - var completedTask = await Task.WhenAny(connectTask, Task.Delay(timeout)).ConfigureAwait(false); - if (completedTask != connectTask) - { - return null; - } - - await connectTask.ConfigureAwait(false); - if (!ipcClient.IsConnected) - { - return null; - } - - var shellProxy = ipcClient.CreateProxy(); - var activationTask = shellProxy.ActivateMainWindowWithStatusAsync(); - completedTask = await Task.WhenAny(activationTask, Task.Delay(timeout)).ConfigureAwait(false); - if (completedTask != activationTask) - { - return null; - } - - return await activationTask.ConfigureAwait(false); - } - catch (Exception ex) - { - Logger.Warn($"Failed to activate the existing desktop instance: {ex.Message}"); - return null; - } - } - - private static bool TryGetLiveProcess(int processId) - { - if (processId <= 0) - { - return false; - } - - try - { - using var process = Process.GetProcessById(processId); - return !process.HasExited; - } - catch - { - return false; - } - } - - 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 - { - await Dispatcher.UIThread.InvokeAsync(() => window.Report("verify", Strings.Update_Verifying, 10)); - var updateResult = await updateEngine.ApplyPendingUpdateAsync().ConfigureAwait(false); - if (!updateResult.Success) - { - success = false; - errorMessage = updateResult.Message; - } - - if (success) - { - await Dispatcher.UIThread.InvokeAsync(() => window.Report("plugins", Strings.Update_ApplyingPlugins, 60)); - var pluginsDir = context.GetOption("plugins-dir") ?? Path.Combine(appRoot, "plugins"); - var queueResult = pluginUpgrades.ApplyPendingUpgrades(pluginsDir); - if (!queueResult.Success && queueResult.Code != "noop") - { - Logger.Error($"Plugin upgrade failed during apply-update: {queueResult.Message}"); - } - } - - if (success) - { - await Dispatcher.UIThread.InvokeAsync(() => window.Report("cleanup", Strings.Update_CleaningUp, 90)); - deploymentLocator.CleanupOldDeployments(minVersionsToKeep: 3); - } - } - catch (Exception ex) - { - success = false; - errorMessage = ex.Message; - Logger.Error("Apply-update flow failed.", ex); - } - - await Dispatcher.UIThread.InvokeAsync(() => window.ReportComplete(success, errorMessage)); - await Task.Delay(success ? 1500 : 5000).ConfigureAwait(false); - - 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"), - Details = new Dictionary(StringComparer.OrdinalIgnoreCase) - { - ["command"] = context.Command, - ["launchSource"] = context.LaunchSource - } - }).ConfigureAwait(false); - - Environment.ExitCode = success ? 0 : 1; - await Dispatcher.UIThread.InvokeAsync(() => desktop.Shutdown(Environment.ExitCode), DispatcherPriority.Background); - } } diff --git a/LanMountainDesktop.Launcher/Shell/EntryHandlers/LaunchEntryHandlers.cs b/LanMountainDesktop.Launcher/Shell/EntryHandlers/LaunchEntryHandlers.cs new file mode 100644 index 0000000..d9f5e23 --- /dev/null +++ b/LanMountainDesktop.Launcher/Shell/EntryHandlers/LaunchEntryHandlers.cs @@ -0,0 +1,90 @@ +using Avalonia.Controls.ApplicationLifetimes; +using Avalonia.Threading; +using LanMountainDesktop.Launcher.Models; +using LanMountainDesktop.Launcher.Views; + +namespace LanMountainDesktop.Launcher.Shell.EntryHandlers; + +internal static class LaunchEntryHandler +{ + public static SplashWindow CreateSplashWindow() + { + var window = new SplashWindow(); + try + { + var appRoot = Commands.ResolveAppRoot(LauncherRuntimeContext.Current); + var versionInfo = new DeploymentLocator(appRoot).GetVersionInfo(); + window.SetVersionInfo(versionInfo.Version, versionInfo.Codename); + } + catch (Exception ex) + { + Logger.Warn($"Failed to set splash version info: {ex.Message}"); + } + + return window; + } + + public static Task RunAsync( + IClassicDesktopStyleApplicationLifetime desktop, + CommandContext context, + SplashWindow splashWindow) => + LauncherCompositionRoot.RunOrchestratorWithSplashAsync(desktop, context, splashWindow); +} + +internal static class ApplyUpdateEntryHandler +{ + public static Task RunAsync( + IClassicDesktopStyleApplicationLifetime desktop, + CommandContext context, + UpdateWindow window) => + LauncherCompositionRoot.RunApplyUpdateWithWindowAsync(desktop, context, window); +} + +internal static class AirAppBrokerEntryHandler +{ + public static async Task RunAsync(IClassicDesktopStyleApplicationLifetime desktop, CommandContext context) + { + var appRoot = Commands.ResolveAppRoot(context); + var requesterPid = context.GetIntOption("requester-pid", 0); + var dataLocationResolver = new DataLocationResolver(appRoot); + Logger.Info($"Air APP broker starting. AppRoot='{appRoot}'; RequesterPid={requesterPid}."); + + using var airAppIpcHost = new LauncherAirAppLifecycleIpcHost( + new LauncherAirAppLifecycleService( + new AirAppProcessStarter( + new AirAppHostLocator(), + () => appRoot, + () => null, + () => dataLocationResolver.ResolveDataRoot()))); + airAppIpcHost.Start(); + + while (ShouldKeepAlive(requesterPid, airAppIpcHost.LifecycleService)) + { + await Task.Delay(TimeSpan.FromSeconds(2)).ConfigureAwait(false); + } + + Logger.Info("Air APP broker exiting."); + await Dispatcher.UIThread.InvokeAsync(() => desktop.Shutdown(0), DispatcherPriority.Background); + } + + internal static bool ShouldKeepAirAppBrokerAlive(int requesterPid, LauncherAirAppLifecycleService lifecycleService) + { + if (requesterPid <= 0) + { + return lifecycleService.HasLiveAirApps(); + } + + try + { + using var process = System.Diagnostics.Process.GetProcessById(requesterPid); + return !process.HasExited || lifecycleService.HasLiveAirApps(); + } + catch + { + return lifecycleService.HasLiveAirApps(); + } + } + + private static bool ShouldKeepAlive(int requesterPid, LauncherAirAppLifecycleService lifecycleService) => + ShouldKeepAirAppBrokerAlive(requesterPid, lifecycleService); +} diff --git a/LanMountainDesktop.Launcher/Shell/EntryHandlers/PreviewEntryHandler.cs b/LanMountainDesktop.Launcher/Shell/EntryHandlers/PreviewEntryHandler.cs new file mode 100644 index 0000000..a6489a4 --- /dev/null +++ b/LanMountainDesktop.Launcher/Shell/EntryHandlers/PreviewEntryHandler.cs @@ -0,0 +1,136 @@ +using Avalonia.Controls; +using Avalonia.Controls.ApplicationLifetimes; +using Avalonia.Threading; +using LanMountainDesktop.Launcher.Models; +using LanMountainDesktop.Launcher.Resources; +using LanMountainDesktop.Launcher.Views; + +namespace LanMountainDesktop.Launcher.Shell.EntryHandlers; + +internal static class PreviewEntryHandler +{ + public static bool TryHandle(CommandContext context, IClassicDesktopStyleApplicationLifetime desktop) + { + switch (context.Command.ToLowerInvariant()) + { + case "preview-splash": + RunSplashPreview(desktop); + return true; + case "preview-error": + RunErrorPreview(desktop); + return true; + case "preview-multi-instance": + RunMultiInstancePreview(desktop); + return true; + case "preview-update": + RunUpdatePreview(desktop); + return true; + case "preview-oobe": + RunOobePreview(desktop); + return true; + case "preview-debug": + new DevDebugWindow().Show(); + return true; + default: + return false; + } + } + + private static void RunSplashPreview(IClassicDesktopStyleApplicationLifetime desktop) + { + var splashWindow = LaunchEntryHandler.CreateSplashWindow(); + splashWindow.SetDebugMode(true); + splashWindow.Show(); + _ = SimulateSplashPreviewAsync(desktop, splashWindow); + } + + private static void RunErrorPreview(IClassicDesktopStyleApplicationLifetime desktop) + { + var errorWindow = new ErrorWindow(); + errorWindow.SetErrorMessage(Strings.Preview_ErrorMessage); + errorWindow.Show(); + _ = WaitForWindowCloseAsync(desktop, errorWindow); + } + + private static void RunMultiInstancePreview(IClassicDesktopStyleApplicationLifetime desktop) + { + var promptWindow = new MultiInstancePromptWindow(); + promptWindow.SetDetails(Environment.ProcessId, "ForegroundDesktop"); + promptWindow.Show(); + _ = WaitForWindowCloseAsync(desktop, promptWindow); + } + + private static void RunUpdatePreview(IClassicDesktopStyleApplicationLifetime desktop) + { + var updateWindow = new UpdateWindow(); + updateWindow.SetDebugMode(true); + updateWindow.Show(); + _ = SimulateUpdatePreviewAsync(desktop, updateWindow); + } + + private static void RunOobePreview(IClassicDesktopStyleApplicationLifetime desktop) + { + var oobeWindow = new OobeWindow(); + oobeWindow.Show(); + _ = SimulateOobePreviewAsync(desktop, oobeWindow); + } + + private static async Task SimulateSplashPreviewAsync(IClassicDesktopStyleApplicationLifetime desktop, SplashWindow window) + { + var stages = new[] { "initializing", "update", "plugins", "launch", "ready" }; + var messages = new[] + { + Strings.Preview_SplashInitializing, + Strings.Preview_SplashCheckingUpdates, + Strings.Preview_SplashCheckingPlugins, + Strings.Preview_SplashLaunchingHost, + Strings.Preview_SplashReady + }; + var reporter = (ISplashStageReporter)window; + + for (var i = 0; i < stages.Length; i++) + { + reporter.Report(stages[i], messages[i]); + await Task.Delay(800).ConfigureAwait(false); + } + + await Task.Delay(5000).ConfigureAwait(false); + await Dispatcher.UIThread.InvokeAsync(() => desktop.Shutdown(0)); + } + + private static async Task SimulateUpdatePreviewAsync(IClassicDesktopStyleApplicationLifetime desktop, UpdateWindow window) + { + var stages = new[] { "verify", "extract", "apply", "plugins", "cleanup" }; + for (var i = 0; i < stages.Length; i++) + { + window.Report(stages[i], string.Format(Strings.Preview_UpdateProcessing, stages[i]), (i + 1) * 20); + await Task.Delay(600).ConfigureAwait(false); + } + + window.ReportComplete(true, null); + await Task.Delay(3000).ConfigureAwait(false); + await Dispatcher.UIThread.InvokeAsync(() => desktop.Shutdown(0)); + } + + private static async Task SimulateOobePreviewAsync(IClassicDesktopStyleApplicationLifetime desktop, OobeWindow window) + { + try + { + await window.WaitForEnterAsync().ConfigureAwait(false); + } + catch (Exception ex) + { + Logger.Error("OOBE preview failed.", ex); + } + + await Dispatcher.UIThread.InvokeAsync(() => desktop.Shutdown(0)); + } + + private static async Task WaitForWindowCloseAsync(IClassicDesktopStyleApplicationLifetime desktop, Window window) + { + var tcs = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); + window.Closed += (_, _) => tcs.TrySetResult(); + await tcs.Task.ConfigureAwait(false); + await Dispatcher.UIThread.InvokeAsync(() => desktop.Shutdown(0)); + } +} diff --git a/LanMountainDesktop.Launcher/Shell/LauncherCompositionRoot.cs b/LanMountainDesktop.Launcher/Shell/LauncherCompositionRoot.cs new file mode 100644 index 0000000..dca4f6d --- /dev/null +++ b/LanMountainDesktop.Launcher/Shell/LauncherCompositionRoot.cs @@ -0,0 +1,620 @@ +using System.Diagnostics; +using Avalonia.Controls.ApplicationLifetimes; +using Avalonia.Threading; +using LanMountainDesktop.Launcher.Models; +using LanMountainDesktop.Launcher.Resources; +using LanMountainDesktop.Launcher.Views; +using LanMountainDesktop.Shared.Contracts.Launcher; +using LanMountainDesktop.Shared.IPC; +using LanMountainDesktop.Shared.IPC.Abstractions.Services; + +namespace LanMountainDesktop.Launcher.Shell; + +/// +/// Launcher GUI 入口装配:创建编排器并驱动启动流程。 +/// +internal static class LauncherCompositionRoot +{ + public static LauncherOrchestrator CreateOrchestrator( + CommandContext context, + string appRoot, + StartupAttemptRegistry startupAttemptRegistry, + LauncherCoordinatorIpcServer coordinatorServer) + { + var deploymentLocator = new DeploymentLocator(appRoot); + return new LauncherOrchestrator( + context, + deploymentLocator, + new OobeStateService(appRoot), + new UpdateEngineService(deploymentLocator), + startupAttemptRegistry, + coordinatorServer); + } + + public static async Task RunOrchestratorWithSplashAsync( + IClassicDesktopStyleApplicationLifetime desktop, + CommandContext context, + SplashWindow splashWindow) + { + LauncherResult result; + SplashWindow? currentSplashWindow = splashWindow; + var appRoot = Commands.ResolveAppRoot(context); + var dataLocationResolver = new DataLocationResolver(appRoot); + var startupAttemptRegistry = new StartupAttemptRegistry(); + var coordinatorPipeName = LauncherCoordinatorIpcServer.CreatePipeName(); + var successPolicy = LauncherOrchestrator.ResolveSuccessPolicyKey(context); + if (!startupAttemptRegistry.TryReserveCoordinator( + context.LaunchSource, + successPolicy, + coordinatorPipeName, + out var reservedAttempt, + out var activeCoordinatorAttempt)) + { + result = await AttachToExistingCoordinatorAsync( + context, + currentSplashWindow, + activeCoordinatorAttempt).ConfigureAwait(false); + + Logger.Info($"Secondary launcher completed. Success={result.Success}; Code='{result.Code}'."); + await WriteLauncherResultAsync(context, result).ConfigureAwait(false); + + Environment.ExitCode = result.Success ? 0 : 1; + await Dispatcher.UIThread.InvokeAsync(() => desktop.Shutdown(Environment.ExitCode), DispatcherPriority.Background); + return; + } + + using var airAppIpcHost = new LauncherAirAppLifecycleIpcHost( + new LauncherAirAppLifecycleService( + new AirAppProcessStarter( + new AirAppHostLocator(), + () => appRoot, + () => null, + () => dataLocationResolver.ResolveDataRoot()))); + airAppIpcHost.Start(); + + using var coordinatorServer = new LauncherCoordinatorIpcServer( + coordinatorPipeName, + BuildCoordinatorStatusFromAttempt(reservedAttempt), + HandleCoordinatorRequestAsync, + startupAttemptRegistry.UpdateOwnedCoordinatorHeartbeat); + coordinatorServer.Start(); + + while (true) + { + try + { + Logger.Info( + $"Coordinator start. Command='{context.Command}'; AppRoot='{appRoot}'; " + + $"IsDebugMode={context.IsDebugMode}; LaunchSource='{context.LaunchSource}'; " + + $"ResultPath='{context.GetOption("result") ?? ""}'."); + + var orchestrator = CreateOrchestrator( + context, + appRoot, + startupAttemptRegistry, + coordinatorServer); + + result = await orchestrator.RunAsync(currentSplashWindow).ConfigureAwait(false); + } + catch (Exception ex) + { + Logger.Error("Coordinator threw an unhandled exception.", ex); + result = new LauncherResult + { + Success = false, + Stage = "launch", + Code = "exception", + Message = $"Launcher failed: {ex.Message}", + ErrorMessage = ex.ToString() + }; + } + + if (result.Success || + result.Code == "host_not_found" || + (!string.Equals(result.Stage, "launch", StringComparison.OrdinalIgnoreCase) && + !string.Equals(result.Stage, "launchHost", StringComparison.OrdinalIgnoreCase))) + { + break; + } + + var failureAction = await ShowFailureWindowAsync(result).ConfigureAwait(false); + if (failureAction == ErrorWindowResult.Exit) + { + break; + } + + if (failureAction == ErrorWindowResult.ActivateExisting && + await TryActivateExistingInstanceAsync().ConfigureAwait(false)) + { + result = new LauncherResult + { + Success = true, + Stage = "launch", + Code = "activation_requested", + Message = "Launcher activated the existing desktop instance.", + Details = result.Details + }; + break; + } + + currentSplashWindow = CreateSplashWindow(); + currentSplashWindow.Show(); + } + + Logger.Info($"Coordinator completed. Success={result.Success}; Stage='{result.Stage}'; Code='{result.Code}'."); + await WriteLauncherResultAsync(context, result).ConfigureAwait(false); + + Environment.ExitCode = result.Success ? 0 : 1; + if (result.Success) + { + var hostPid = ResolveManagedHostPid(result, startupAttemptRegistry.GetOwnedAttempt()?.HostPid ?? 0); + await WaitForManagedProcessesToExitAsync(hostPid, airAppIpcHost.LifecycleService).ConfigureAwait(false); + } + + await Dispatcher.UIThread.InvokeAsync(() => desktop.Shutdown(Environment.ExitCode), DispatcherPriority.Background); + } + + public 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 + { + await Dispatcher.UIThread.InvokeAsync(() => window.Report("verify", Strings.Update_Verifying, 10)); + var updateResult = await updateEngine.ApplyPendingUpdateAsync().ConfigureAwait(false); + if (!updateResult.Success) + { + success = false; + errorMessage = updateResult.Message; + } + + if (success) + { + await Dispatcher.UIThread.InvokeAsync(() => window.Report("plugins", Strings.Update_ApplyingPlugins, 60)); + var pluginsDir = context.GetOption("plugins-dir") ?? Path.Combine(appRoot, "plugins"); + var queueResult = pluginUpgrades.ApplyPendingUpgrades(pluginsDir); + if (!queueResult.Success && queueResult.Code != "noop") + { + Logger.Error($"Plugin upgrade failed during apply-update: {queueResult.Message}"); + } + } + + if (success) + { + await Dispatcher.UIThread.InvokeAsync(() => window.Report("cleanup", Strings.Update_CleaningUp, 90)); + deploymentLocator.CleanupOldDeployments(minVersionsToKeep: 3); + } + } + catch (Exception ex) + { + success = false; + errorMessage = ex.Message; + Logger.Error("Apply-update flow failed.", ex); + } + + await Dispatcher.UIThread.InvokeAsync(() => window.ReportComplete(success, errorMessage)); + await Task.Delay(success ? 1500 : 5000).ConfigureAwait(false); + + 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"), + Details = new Dictionary(StringComparer.OrdinalIgnoreCase) + { + ["command"] = context.Command, + ["launchSource"] = context.LaunchSource + } + }).ConfigureAwait(false); + + Environment.ExitCode = success ? 0 : 1; + await Dispatcher.UIThread.InvokeAsync(() => desktop.Shutdown(Environment.ExitCode), DispatcherPriority.Background); + } + + private static SplashWindow CreateSplashWindow() + { + var window = new SplashWindow(); + TrySetSplashVersionInfo(window, LauncherRuntimeContext.Current); + return window; + } + + private static void TrySetSplashVersionInfo(SplashWindow window, CommandContext context) + { + try + { + var appRoot = Commands.ResolveAppRoot(context); + var versionInfo = new DeploymentLocator(appRoot).GetVersionInfo(); + window.SetVersionInfo(versionInfo.Version, versionInfo.Codename); + } + catch (Exception ex) + { + Logger.Warn($"Failed to set splash version info before coordinator start: {ex.Message}"); + } + } + + private static int ResolveManagedHostPid(LauncherResult result, int fallbackHostPid) + { + if (result.Details.TryGetValue("hostPid", out var hostPidText) && + int.TryParse(hostPidText, out var hostPid)) + { + return hostPid; + } + + if (result.Details.TryGetValue("existingHostPid", out var existingHostPidText) && + int.TryParse(existingHostPidText, out var existingHostPid)) + { + return existingHostPid; + } + + return fallbackHostPid; + } + + private static async Task WaitForManagedProcessesToExitAsync( + int hostPid, + LauncherAirAppLifecycleService airAppLifecycleService) + { + Logger.Info($"Launcher entering managed background lifetime. HostPid={hostPid}."); + while (TryGetLiveProcess(hostPid) || airAppLifecycleService.HasLiveAirApps()) + { + await Task.Delay(TimeSpan.FromSeconds(2)).ConfigureAwait(false); + } + + Logger.Info("Launcher managed background lifetime completed; no host or Air APP process remains."); + } + + private static async Task AttachToExistingCoordinatorAsync( + CommandContext context, + SplashWindow? splashWindow, + StartupAttemptRecord? activeCoordinatorAttempt) + { + var reporter = splashWindow as ISplashStageReporter; + reporter?.Report("activation", Strings.Preview_ActivationConnecting); + + if (activeCoordinatorAttempt is not null && + !string.IsNullOrWhiteSpace(activeCoordinatorAttempt.CoordinatorPipeName)) + { + var command = string.Equals(context.LaunchSource, "restart", StringComparison.OrdinalIgnoreCase) + ? LauncherCoordinatorCommands.Attach + : LauncherCoordinatorCommands.ActivateDesktop; + var request = new LauncherCoordinatorRequest + { + Command = command, + LaunchSource = context.LaunchSource, + SuccessPolicy = LauncherOrchestrator.ResolveSuccessPolicyKey(context) + }; + + var response = await new LauncherCoordinatorIpcClient() + .SendAsync(activeCoordinatorAttempt.CoordinatorPipeName, request, TimeSpan.FromSeconds(2)) + .ConfigureAwait(false); + + if (response is not null) + { + reporter?.Report("activation", response.Message); + await DismissSplashIfNeededAsync(splashWindow).ConfigureAwait(false); + var success = response.Accepted || + IsRecoverableActivationFailure(response.ActivationResult, response.Status); + return new LauncherResult + { + Success = success, + Stage = "launch", + Code = success && !response.Accepted ? "attached_to_launcher_coordinator" : response.Code, + Message = success && !response.Accepted + ? "Attached to the active Launcher coordinator; desktop startup is still in progress." + : response.Message, + Details = BuildCoordinatorResultDetails(response.Status, response.ActivationResult) + }; + } + } + + var activation = await TryActivateExistingInstanceWithStatusAsync(TimeSpan.FromSeconds(2)).ConfigureAwait(false); + if (activation is not null) + { + reporter?.Report("activation", activation.Message); + await DismissSplashIfNeededAsync(splashWindow).ConfigureAwait(false); + var success = activation.Accepted || IsRecoverableActivationFailure(activation, null); + return new LauncherResult + { + Success = success, + Stage = "launch", + Code = activation.Accepted + ? "existing_host_activated" + : success + ? "existing_host_startup_pending" + : "existing_host_activation_failed", + Message = success && !activation.Accepted + ? "Existing desktop process is still starting; Launcher attached without starting another process." + : activation.Message, + Details = BuildCoordinatorResultDetails(null, activation) + }; + } + + await DismissSplashIfNeededAsync(splashWindow).ConfigureAwait(false); + return new LauncherResult + { + Success = false, + Stage = "launch", + Code = "launcher_coordinator_unavailable", + Message = "Another Launcher is coordinating startup, but it did not respond in time.", + Details = new Dictionary(StringComparer.OrdinalIgnoreCase) + { + ["activeCoordinatorPid"] = activeCoordinatorAttempt?.CoordinatorPid.ToString() ?? string.Empty, + ["activeCoordinatorPipeName"] = activeCoordinatorAttempt?.CoordinatorPipeName ?? string.Empty, + ["activeAttemptId"] = activeCoordinatorAttempt?.AttemptId ?? string.Empty, + ["activeHostPid"] = activeCoordinatorAttempt?.HostPid.ToString() ?? string.Empty + } + }; + } + + private static async Task HandleCoordinatorRequestAsync( + LauncherCoordinatorRequest request, + LauncherCoordinatorStatus status) + { + if (string.Equals(request.Command, LauncherCoordinatorCommands.ActivateDesktop, StringComparison.OrdinalIgnoreCase)) + { + var activation = await TryActivateExistingInstanceWithStatusAsync(TimeSpan.FromSeconds(2)).ConfigureAwait(false); + if (activation is not null) + { + if (!activation.Accepted && IsRecoverableActivationFailure(activation, status)) + { + return new LauncherCoordinatorResponse + { + Accepted = true, + Code = "attached_to_launcher_coordinator", + Message = "Attached to the active Launcher coordinator; desktop startup is still in progress.", + Status = status, + ActivationResult = activation + }; + } + + return new LauncherCoordinatorResponse + { + Accepted = activation.Accepted, + Code = activation.Accepted ? "existing_host_activated" : "existing_host_activation_failed", + Message = activation.Message, + Status = status, + ActivationResult = activation + }; + } + + return new LauncherCoordinatorResponse + { + Accepted = true, + Code = "attached_to_launcher_coordinator", + Message = "Attached to the active Launcher coordinator; desktop startup is still in progress.", + Status = status + }; + } + + return new LauncherCoordinatorResponse + { + Accepted = true, + Code = "attached_to_launcher_coordinator", + Message = "Attached to the active Launcher coordinator.", + Status = status + }; + } + + private static LauncherCoordinatorStatus BuildCoordinatorStatusFromAttempt(StartupAttemptRecord attempt) + { + return new LauncherCoordinatorStatus + { + AttemptId = attempt.AttemptId, + CoordinatorPid = Environment.ProcessId, + HostPid = attempt.HostPid, + HostProcessAlive = TryGetLiveProcess(attempt.HostPid), + LaunchSource = attempt.LaunchSource, + SuccessPolicy = attempt.SuccessPolicy, + LastObservedStage = attempt.LastObservedStage, + LastObservedMessage = attempt.LastObservedMessage, + PublicIpcConnected = attempt.PublicIpcConnected || attempt.IpcConnected, + State = attempt.State.ToString(), + SoftTimeoutShown = attempt.State is StartupAttemptState.SoftTimeout or StartupAttemptState.DetachedWaiting, + Completed = attempt.State is StartupAttemptState.Succeeded or StartupAttemptState.Failed, + Succeeded = attempt.State == StartupAttemptState.Succeeded, + UpdatedAtUtc = attempt.UpdatedAtUtc + }; + } + + private static bool IsRecoverableActivationFailure( + PublicShellActivationResult? activation, + LauncherCoordinatorStatus? status) + { + if (activation is { Accepted: true }) + { + return false; + } + + if (status is { Completed: false, HostProcessAlive: true }) + { + return true; + } + + var shellStatus = activation?.Status; + if (shellStatus is null || !shellStatus.PublicIpcReady) + { + return false; + } + + return !shellStatus.MainWindowOpened || + !shellStatus.DesktopVisible || + string.Equals(activation?.Code, "shell_not_ready", StringComparison.OrdinalIgnoreCase) || + string.Equals(activation?.Code, "startup_pending", StringComparison.OrdinalIgnoreCase); + } + + private static Dictionary BuildCoordinatorResultDetails( + LauncherCoordinatorStatus? status, + PublicShellActivationResult? activation) + { + return new Dictionary(StringComparer.OrdinalIgnoreCase) + { + ["coordinatorPid"] = status?.CoordinatorPid.ToString() ?? string.Empty, + ["coordinatorAttemptId"] = status?.AttemptId ?? string.Empty, + ["hostPid"] = status?.HostPid.ToString() ?? activation?.Status.ProcessId.ToString() ?? string.Empty, + ["hostProcessAlive"] = status?.HostProcessAlive.ToString() ?? string.Empty, + ["publicIpcConnected"] = (status?.PublicIpcConnected ?? activation is not null).ToString(), + ["startupStage"] = status?.LastObservedStage.ToString() ?? string.Empty, + ["startupState"] = status?.State ?? string.Empty, + ["activationAccepted"] = activation?.Accepted.ToString() ?? string.Empty, + ["shellState"] = activation?.Status.ShellState ?? status?.ShellStatus?.ShellState ?? string.Empty, + ["trayState"] = activation?.Status.Tray.State ?? status?.ShellStatus?.Tray.State ?? string.Empty, + ["taskbarUsable"] = activation?.Status.Taskbar.IsUsable.ToString() ?? status?.ShellStatus?.Taskbar.IsUsable.ToString() ?? string.Empty + }; + } + + private static async Task DismissSplashIfNeededAsync(SplashWindow? splashWindow) + { + if (splashWindow is null) + { + return; + } + + try + { + await splashWindow.DismissAsync().ConfigureAwait(false); + } + catch (Exception ex) + { + Logger.Warn($"Failed to dismiss splash after coordinator attach: {ex.Message}"); + } + } + + private static async Task WriteLauncherResultAsync(CommandContext context, LauncherResult result) + { + var resultPath = context.GetOption("result"); + if (string.IsNullOrWhiteSpace(resultPath)) + { + return; + } + + try + { + await Commands.WriteResultIfNeededAsync(resultPath, result).ConfigureAwait(false); + Logger.Info($"Launcher result written to '{Path.GetFullPath(resultPath)}'."); + } + catch (Exception ex) + { + Logger.Error($"Failed to write launcher result to '{resultPath}'.", ex); + } + } + + private static async Task ShowFailureWindowAsync(LauncherResult result) + { + ErrorWindow? errorWindow = null; + var hostProcessAlive = result.Details.TryGetValue("hostProcessAlive", out var hostProcessAliveText) && + bool.TryParse(hostProcessAliveText, out var hostProcessAliveValue) && + hostProcessAliveValue; + var hostPid = result.Details.TryGetValue("hostPid", out var hostPidText) && + int.TryParse(hostPidText, out var parsedPid) + ? parsedPid + : (int?)null; + + await Dispatcher.UIThread.InvokeAsync(() => + { + try + { + errorWindow = new ErrorWindow(); + if (hostProcessAlive) + { + errorWindow.ConfigureForRunningHostFailure(hostPid); + } + else + { + errorWindow.ConfigureForGenericFailure(allowRetry: true); + } + + errorWindow.SetErrorMessage( + $"Failed to start LanMountainDesktop.\n\nStage: {result.Stage}\nCode: {result.Code}\n\n{result.Message}"); + errorWindow.Show(); + } + catch (Exception ex) + { + Logger.Error("Failed to show launcher failure window.", ex); + } + }); + + if (errorWindow is null) + { + return ErrorWindowResult.Exit; + } + + try + { + return await errorWindow.WaitForChoiceAsync().ConfigureAwait(false); + } + catch (Exception ex) + { + Logger.Error("Failure window closed unexpectedly.", ex); + return ErrorWindowResult.Exit; + } + } + + private static async Task TryActivateExistingInstanceAsync() + { + var activation = await TryActivateExistingInstanceWithStatusAsync(TimeSpan.FromSeconds(5)).ConfigureAwait(false); + return activation?.Accepted == true; + } + + private static async Task TryActivateExistingInstanceWithStatusAsync(TimeSpan timeout) + { + try + { + using var ipcClient = new LanMountainDesktopIpcClient(); + var connectTask = ipcClient.ConnectAsync(); + var completedTask = await Task.WhenAny(connectTask, Task.Delay(timeout)).ConfigureAwait(false); + if (completedTask != connectTask) + { + return null; + } + + await connectTask.ConfigureAwait(false); + if (!ipcClient.IsConnected) + { + return null; + } + + var shellProxy = ipcClient.CreateProxy(); + var activationTask = shellProxy.ActivateMainWindowWithStatusAsync(); + completedTask = await Task.WhenAny(activationTask, Task.Delay(timeout)).ConfigureAwait(false); + if (completedTask != activationTask) + { + return null; + } + + return await activationTask.ConfigureAwait(false); + } + catch (Exception ex) + { + Logger.Warn($"Failed to activate the existing desktop instance: {ex.Message}"); + return null; + } + } + + private static bool TryGetLiveProcess(int processId) + { + if (processId <= 0) + { + return false; + } + + try + { + using var process = Process.GetProcessById(processId); + return !process.HasExited; + } + catch + { + return false; + } + } +} diff --git a/LanMountainDesktop.Launcher/Shell/LauncherOrchestrator.cs b/LanMountainDesktop.Launcher/Shell/LauncherOrchestrator.cs new file mode 100644 index 0000000..fc1ac0e --- /dev/null +++ b/LanMountainDesktop.Launcher/Shell/LauncherOrchestrator.cs @@ -0,0 +1,282 @@ +using Avalonia.Threading; +using LanMountainDesktop.Launcher.Models; +using LanMountainDesktop.Launcher.Startup; +using LanMountainDesktop.Launcher.Views; +using LanMountainDesktop.Shared.Contracts.Launcher; +using LanMountainDesktop.Shared.IPC; +using LanMountainDesktop.Shared.IPC.Abstractions.Services; + +namespace LanMountainDesktop.Launcher.Shell; + +internal sealed class LauncherOrchestrator +{ + private readonly CommandContext _context; + private readonly DeploymentLocator _deploymentLocator; + private readonly OobeStateService _oobeStateService; + private readonly UpdateEngineService _updateEngine; + private readonly StartupAttemptRegistry _startupAttemptRegistry; + private readonly LauncherCoordinatorIpcServer? _coordinatorIpcServer; + private readonly DataLocationResolver _dataLocationResolver; + private readonly IReadOnlyList _oobeSteps; + private readonly LaunchPipeline _pipeline; + + public LauncherOrchestrator( + CommandContext context, + DeploymentLocator deploymentLocator, + OobeStateService oobeStateService, + UpdateEngineService updateEngine, + StartupAttemptRegistry startupAttemptRegistry, + LauncherCoordinatorIpcServer? coordinatorIpcServer = null) + { + _context = context; + _deploymentLocator = deploymentLocator; + _oobeStateService = oobeStateService; + _updateEngine = updateEngine; + _startupAttemptRegistry = startupAttemptRegistry; + _coordinatorIpcServer = coordinatorIpcServer; + _dataLocationResolver = new DataLocationResolver(deploymentLocator.GetAppRoot()); + _oobeSteps = + [ + new WelcomeOobeStep(_oobeStateService, _context), + new DataLocationOobeStep(_dataLocationResolver) + ]; + _pipeline = new LaunchPipeline( + [ + new CleanupDeploymentsPhase(), + new ExistingHostProbePhase(), + new ApplyPendingUpdatePhase(), + new OobeGatePhase(), + new LaunchHostPhase(), + new MonitorStartupPhase() + ]); + } + + public static string ResolveSuccessPolicyKey(CommandContext context) => + new StartupSuccessTracker(context).PolicyKey; + + public async Task RunAsync(SplashWindow? existingSplashWindow = null) + { + try + { + var oobeDecision = _oobeStateService.Evaluate(_context); + if (oobeDecision.ShouldShowOobe) + { + var legacyInfo = LegacyVersionDetector.DetectLegacyInstallation(); + if (legacyInfo is not null) + { + var migrationResult = await LaunchUiPresenter.ShowMigrationPromptAsync(legacyInfo).ConfigureAwait(false); + Logger.Info($"Migration prompt completed. Result='{migrationResult}'."); + } + } + + var splashWindow = existingSplashWindow ?? await Dispatcher.UIThread.InvokeAsync(() => + { + var window = new SplashWindow(); + window.Show(); + return window; + }); + var versionInfo = _deploymentLocator.GetVersionInfo(); + splashWindow.SetVersionInfo(versionInfo.Version, versionInfo.Codename); + var reporter = (ISplashStageReporter)splashWindow; + + LoadingDetailsWindow? loadingDetailsWindow = null; + if (_context.IsDebugMode || _context.GetOption("show-loading-details") == "true") + { + await Dispatcher.UIThread.InvokeAsync(() => + { + loadingDetailsWindow = new LoadingDetailsWindow(); + loadingDetailsWindow.Show(); + }); + } + + var successTcs = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); + var activationFailedTcs = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); + var lastStage = StartupStage.Initializing; + var lastStageMessage = "launcher-started"; + var startupSuccessTracker = new StartupSuccessTracker(_context); + var activationFailureReason = string.Empty; + var ipcConnected = false; + var softTimeoutShown = false; + var attachedToExistingAttempt = false; + var windowsClosingByOrchestrator = false; + StartupAttemptRecord? trackedAttempt = null; + PublicShellStatus? shellStatus = null; + var loadingState = new LoadingStateMessage(); + + void PublishCoordinatorStatus(bool? hostProcessAliveOverride = null, bool completed = false, bool succeeded = false) + { + if (_coordinatorIpcServer is null) + { + return; + } + + trackedAttempt = _startupAttemptRegistry.GetOwnedAttempt() ?? trackedAttempt; + var hostPid = trackedAttempt?.HostPid ?? 0; + var hostProcessAlive = hostProcessAliveOverride ?? + (hostPid > 0 && LaunchResultBuilder.TryGetLiveProcess(hostPid, out _)); + var status = new LauncherCoordinatorStatus + { + AttemptId = trackedAttempt?.AttemptId ?? string.Empty, + CoordinatorPid = Environment.ProcessId, + HostPid = hostPid, + HostProcessAlive = hostProcessAlive, + LaunchSource = trackedAttempt?.LaunchSource ?? _context.LaunchSource, + SuccessPolicy = trackedAttempt?.SuccessPolicy ?? startupSuccessTracker.PolicyKey, + LastObservedStage = lastStage, + LastObservedMessage = lastStageMessage, + PublicIpcConnected = ipcConnected, + State = trackedAttempt?.State.ToString() ?? StartupAttemptState.Pending.ToString(), + SoftTimeoutShown = softTimeoutShown, + Completed = completed, + Succeeded = succeeded, + ShellStatus = shellStatus, + UpdatedAtUtc = DateTimeOffset.UtcNow + }; + + _coordinatorIpcServer.UpdateStatus(status); + _startupAttemptRegistry.UpdateOwnedCoordinatorHeartbeat(status); + } + + trackedAttempt = _startupAttemptRegistry.GetOwnedAttempt(); + PublishCoordinatorStatus(); + + EventHandler? splashClosedHandler = null; + splashClosedHandler = (_, _) => + { + if (windowsClosingByOrchestrator) + { + return; + } + + _startupAttemptRegistry.MarkOwnedDetachedWaiting(); + Logger.Warn("Splash window was closed manually. Launcher will continue monitoring the current startup attempt."); + }; + splashWindow.Closed += splashClosedHandler; + + using var ipcClient = new LanMountainDesktopIpcClient(); + ipcClient.RegisterNotifyHandler(IpcRoutedNotifyIds.LauncherStartupProgress, message => + { + Dispatcher.UIThread.Post(() => + { + try + { + ipcConnected = true; + lastStage = message.Stage; + lastStageMessage = message.Message ?? message.Stage.ToString(); + Logger.Info($"IPC stage received. Stage='{message.Stage}'; Message='{message.Message ?? string.Empty}'."); + + loadingState = loadingState with + { + Stage = message.Stage, + OverallProgressPercent = message.ProgressPercent, + Message = message.Message, + Timestamp = DateTimeOffset.UtcNow + }; + + reporter.Report(LaunchUiPresenter.MapStartupStageToSplashStage(message.Stage), message.Message ?? message.Stage.ToString()); + loadingDetailsWindow?.UpdateLoadingState(loadingState); + _startupAttemptRegistry.UpdateOwnedStage(message.Stage, message.Message, ipcConnected: true); + PublishCoordinatorStatus(); + + if (startupSuccessTracker.TryResolve(message.Stage, out var successState)) + { + successTcs.TrySetResult(successState); + } + + if (message.Stage == StartupStage.ActivationFailed) + { + activationFailureReason = message.Message ?? "activation_failed"; + activationFailedTcs.TrySetResult(message.Message ?? "activation_failed"); + } + } + catch (Exception ex) + { + Logger.Error("IPC progress callback failed.", ex); + } + }); + }); + ipcClient.RegisterNotifyHandler(IpcRoutedNotifyIds.LauncherLoadingState, message => + { + Dispatcher.UIThread.Post(() => + { + try + { + loadingState = message; + loadingDetailsWindow?.UpdateLoadingState(loadingState); + } + catch (Exception ex) + { + Logger.Error("IPC loading-state callback failed.", ex); + } + }); + }); + + var launchContext = new LaunchContext + { + CommandContext = _context, + DeploymentLocator = _deploymentLocator, + OobeStateService = _oobeStateService, + UpdateEngine = _updateEngine, + StartupAttemptRegistry = _startupAttemptRegistry, + CoordinatorIpcServer = _coordinatorIpcServer, + DataLocationResolver = _dataLocationResolver, + OobeSteps = _oobeSteps, + SplashWindow = splashWindow, + LoadingDetailsWindow = loadingDetailsWindow, + Reporter = reporter, + IpcClient = ipcClient, + SuccessTracker = startupSuccessTracker, + SuccessTcs = successTcs, + ActivationFailedTcs = activationFailedTcs, + LoadingState = loadingState, + PublishCoordinatorStatus = PublishCoordinatorStatus, + SplashClosedHandler = splashClosedHandler + }; + + try + { + var result = await _pipeline.ExecuteAsync(launchContext).ConfigureAwait(false); + windowsClosingByOrchestrator = launchContext.WindowsClosingByOrchestrator; + return result; + } + finally + { + if (splashClosedHandler is not null) + { + splashWindow.Closed -= splashClosedHandler; + } + + if (!windowsClosingByOrchestrator && !launchContext.WindowsClosingByOrchestrator) + { + await Dispatcher.UIThread.InvokeAsync(() => + { + try + { + if (splashWindow.IsVisible && splashWindow.IsLoaded) + { + splashWindow.Close(); + Logger.Info("Splash window closed in orchestrator cleanup."); + } + } + catch (Exception ex) + { + Logger.Error("Failed to close splash window during orchestrator cleanup.", ex); + } + }); + } + } + } + catch (Exception ex) + { + Logger.Error("Launcher orchestrator failed.", ex); + var oobeDecision = _oobeStateService.Evaluate(_context); + return LaunchResultBuilder.Build( + false, + "launch", + "exception", + ex.Message, + LaunchResultBuilder.BuildLauncherContextDetails(_context, oobeDecision, _deploymentLocator.GetAppRoot()), + ex.ToString()); + } + } +} diff --git a/LanMountainDesktop.Launcher/Startup/ExistingHostProbe.cs b/LanMountainDesktop.Launcher/Startup/ExistingHostProbe.cs new file mode 100644 index 0000000..5851963 --- /dev/null +++ b/LanMountainDesktop.Launcher/Startup/ExistingHostProbe.cs @@ -0,0 +1,162 @@ +using LanMountainDesktop.Launcher.Views; +using LanMountainDesktop.Shared.Contracts.Launcher; +using LanMountainDesktop.Shared.IPC; +using LanMountainDesktop.Shared.IPC.Abstractions.Services; + +namespace LanMountainDesktop.Launcher.Startup; + +internal static class ExistingHostProbe +{ + public static MultiInstanceLaunchBehavior LoadMultiInstanceLaunchBehavior(DataLocationResolver dataLocationResolver) + { + try + { + var settingsPath = HostAppSettingsOobeMerger.GetSettingsFilePath(dataLocationResolver.ResolveDataRoot()); + return HostAppSettingsOobeMerger.LoadMultiInstanceLaunchBehavior(settingsPath); + } + catch (Exception ex) + { + Logger.Warn($"Failed to load multi-instance launch behavior. Falling back to default. {ex.Message}"); + return MultiInstanceLaunchBehavior.NotifyAndOpenDesktop; + } + } + + public static async Task TryGetExistingHostStatusAsync( + LanMountainDesktopIpcClient ipcClient, + TimeSpan timeout) + { + try + { + var connected = ipcClient.IsConnected || + await PublicIpcConnection.TryConnectAsync(ipcClient, timeout).ConfigureAwait(false); + if (!connected) + { + return null; + } + + var shellProxy = ipcClient.CreateProxy(); + return await shellProxy.GetShellStatusAsync().ConfigureAwait(false); + } + catch (Exception ex) + { + Logger.Info($"Existing host status probe did not complete: {ex.Message}"); + return null; + } + } + + public static async Task ApplyExistingHostBehaviorAsync( + LanMountainDesktopIpcClient ipcClient, + MultiInstanceLaunchBehavior behavior, + PublicShellStatus status) + { + try + { + var shellProxy = ipcClient.CreateProxy(); + return behavior switch + { + MultiInstanceLaunchBehavior.OpenDesktopSilently => await ActivateExistingHostForBehaviorAsync( + shellProxy, + showLauncherNotice: false, + successCode: "existing_host_activated", + successMessage: "Launcher activated the existing desktop instance.", + failureCode: "existing_host_activation_failed").ConfigureAwait(false), + + MultiInstanceLaunchBehavior.NotifyAndOpenDesktop => await ActivateExistingHostForBehaviorAsync( + shellProxy, + showLauncherNotice: true, + successCode: "existing_host_activated_with_notice", + successMessage: "Launcher activated the existing desktop instance and showed the repeated-launch notice.", + failureCode: "existing_host_activation_failed").ConfigureAwait(false), + + MultiInstanceLaunchBehavior.PromptOnly => await ShowPromptOnlyExistingHostAsync( + shellProxy, + status).ConfigureAwait(false), + + MultiInstanceLaunchBehavior.RestartApp => await RestartExistingHostAsync(shellProxy).ConfigureAwait(false), + + _ => await ActivateExistingHostForBehaviorAsync( + shellProxy, + showLauncherNotice: true, + successCode: "existing_host_activated_with_notice", + successMessage: "Launcher activated the existing desktop instance and showed the repeated-launch notice.", + failureCode: "existing_host_activation_failed").ConfigureAwait(false) + }; + } + catch (Exception ex) + { + Logger.Warn($"Failed to apply multi-instance behavior '{behavior}': {ex.Message}"); + return new ExistingHostBehaviorResult( + false, + "multi_instance_behavior_failed", + $"Failed to apply multi-instance behavior '{behavior}': {ex.Message}", + null); + } + } + + private static async Task ActivateExistingHostForBehaviorAsync( + IPublicShellControlService shellProxy, + bool showLauncherNotice, + string successCode, + string successMessage, + string failureCode) + { + var activation = await shellProxy.ActivateMainWindowWithStatusAsync().ConfigureAwait(false); + var success = activation.Accepted || HostActivationPolicy.IsRecoverableActivationFailure(activation); + if (showLauncherNotice && success) + { + var promptResult = await LaunchUiPresenter.ShowMultiInstancePromptAsync(activation.Status).ConfigureAwait(false); + if (promptResult == MultiInstancePromptResult.OpenDesktop) + { + activation = await shellProxy.ActivateMainWindowWithStatusAsync().ConfigureAwait(false); + } + } + + return new ExistingHostBehaviorResult( + success, + activation.Accepted ? successCode : success ? "existing_host_startup_pending" : failureCode, + activation.Accepted ? successMessage : activation.Message, + activation); + } + + private static async Task RestartExistingHostAsync( + IPublicShellControlService shellProxy) + { + var accepted = await shellProxy.RestartAsync().ConfigureAwait(false); + return new ExistingHostBehaviorResult( + accepted, + accepted ? "existing_host_restart_requested" : "existing_host_restart_failed", + accepted + ? "Launcher requested the existing desktop instance to restart." + : "Launcher could not request restart from the existing desktop instance.", + null); + } + + private static async Task ShowPromptOnlyExistingHostAsync( + IPublicShellControlService shellProxy, + PublicShellStatus status) + { + var promptResult = await LaunchUiPresenter.ShowMultiInstancePromptAsync(status).ConfigureAwait(false); + + if (promptResult == MultiInstancePromptResult.OpenDesktop) + { + return await ActivateExistingHostForBehaviorAsync( + shellProxy, + showLauncherNotice: false, + successCode: "existing_host_activated_from_prompt", + successMessage: "Launcher activated the existing desktop instance from the prompt.", + failureCode: "existing_host_activation_failed").ConfigureAwait(false); + } + + return new ExistingHostBehaviorResult( + true, + "existing_host_prompt_only", + "Launcher showed the repeated-launch prompt and did not open the desktop automatically.", + null); + } +} + +internal sealed record ExistingHostBehaviorResult( + bool Success, + string Code, + string Message, + PublicShellActivationResult? ActivationResult); diff --git a/LanMountainDesktop.Launcher/Startup/HostLaunchModels.cs b/LanMountainDesktop.Launcher/Startup/HostLaunchModels.cs new file mode 100644 index 0000000..841cc56 --- /dev/null +++ b/LanMountainDesktop.Launcher/Startup/HostLaunchModels.cs @@ -0,0 +1,77 @@ +using System.Diagnostics; +using LanMountainDesktop.Launcher.Models; +using LanMountainDesktop.Shared.Contracts.Launcher; + +namespace LanMountainDesktop.Launcher.Startup; + +internal enum HostStartMode +{ + ShellExecute, + Direct +} + +internal sealed record HostStartAttempt( + HostStartMode StartMode, + bool ProcessCreated, + Process? Process, + bool ExitedEarly, + int? ExitCode, + string? FailureReason, + string? PackageRoot, + string? WorkingDirectory, + string? Arguments) +{ + public int? ProcessId => Process?.Id; + + public static HostStartAttempt Started(HostStartMode startMode, Process process, HostLaunchPlan plan) => + new( + startMode, + true, + process, + false, + null, + null, + plan.PackageRoot, + plan.WorkingDirectory, + HostLaunchPlanBuilder.FormatArgumentsForLog(plan.Arguments)); + + public static HostStartAttempt EarlyExit(HostStartMode startMode, Process process, int exitCode, HostLaunchPlan plan) => + new( + startMode, + true, + process, + true, + exitCode, + null, + plan.PackageRoot, + plan.WorkingDirectory, + HostLaunchPlanBuilder.FormatArgumentsForLog(plan.Arguments)); + + public static HostStartAttempt StartFailed(HostStartMode startMode, string failureReason, HostLaunchPlan? plan = null) => + new( + startMode, + false, + null, + false, + null, + failureReason, + plan?.PackageRoot, + plan?.WorkingDirectory, + plan is null ? null : HostLaunchPlanBuilder.FormatArgumentsForLog(plan.Arguments)); +} + +internal sealed record HostLaunchOutcome( + LauncherResult Result, + Process? Process, + LauncherResult? ImmediateResult, + Dictionary Details) +{ + public static HostLaunchOutcome FromResult(LauncherResult result) => + new(result, null, result.Success ? result : null, result.Details); + + public static HostLaunchOutcome FromImmediateResult(LauncherResult result) => + new(result, null, result, result.Details); + + public static HostLaunchOutcome FromProcess(Process process, LauncherResult result, Dictionary details) => + new(result, process, null, details); +} diff --git a/LanMountainDesktop.Launcher/Startup/HostLaunchService.cs b/LanMountainDesktop.Launcher/Startup/HostLaunchService.cs new file mode 100644 index 0000000..19a5c27 --- /dev/null +++ b/LanMountainDesktop.Launcher/Startup/HostLaunchService.cs @@ -0,0 +1,319 @@ +using System.Diagnostics; +using LanMountainDesktop.Launcher.Models; +using LanMountainDesktop.Launcher.Views; +using LanMountainDesktop.Shared.Contracts.Launcher; + +namespace LanMountainDesktop.Launcher.Startup; + +internal sealed class HostLaunchService +{ + public async Task LaunchAsync(LaunchContext context, bool forceDirectMode = false, string? retryTag = null) + { + var commandContext = context.CommandContext; + var deploymentLocator = context.DeploymentLocator; + var resolution = deploymentLocator.ResolveHostExecutable(commandContext); + if (!resolution.Success || string.IsNullOrWhiteSpace(resolution.ResolvedHostPath)) + { + var (errorResult, selectedPath) = await LaunchUiPresenter.ShowHostNotFoundErrorAsync().ConfigureAwait(false); + if (errorResult == ErrorWindowResult.Retry) + { + if (!string.IsNullOrWhiteSpace(selectedPath) && File.Exists(selectedPath)) + { + return await LaunchWithExplicitPathAsync(context, selectedPath, forceDirectMode, retryTag).ConfigureAwait(false); + } + + return await LaunchAsync(context, forceDirectMode, retryTag).ConfigureAwait(false); + } + + return HostLaunchOutcome.FromResult(LaunchResultBuilder.Build( + success: false, + stage: "launchHost", + code: "host_not_found", + message: "LanMountainDesktop host executable was not found.", + details: BuildResolutionDetails(resolution, null, null, "resolve"))); + } + + return await LaunchWithResolvedPathAsync(context, resolution, forceDirectMode, retryTag).ConfigureAwait(false); + } + + public static LauncherResult? ValidateDotNetRuntimePrerequisite( + HostLaunchPlan plan, + HostResolutionResult resolution, + DotNetRuntimeProbeOptions? probeOptions = null) + { + ArgumentNullException.ThrowIfNull(plan); + ArgumentNullException.ThrowIfNull(resolution); + + if (!DotNetRuntimeProbe.IsFrameworkDependentWindowsApp(plan.HostPath)) + { + return null; + } + + var runtime = DotNetRuntimeProbe.Probe(probeOptions); + Logger.Info( + $"Runtime prerequisite check completed. Available={runtime.IsAvailable}; " + + $"Architecture={runtime.Architecture}; Message='{runtime.Message}'."); + + if (runtime.IsAvailable) + { + return null; + } + + var details = BuildResolutionDetails(resolution, null, null, "runtime"); + foreach (var pair in runtime.ToDetails()) + { + details[pair.Key] = pair.Value; + } + + return LaunchResultBuilder.Build( + success: false, + stage: "launchHost", + code: "dotnet_runtime_missing", + message: ".NET 10 Desktop Runtime is required before LanMountainDesktop can start.", + details: details, + errorMessage: runtime.Message); + } + + private static Task LaunchWithExplicitPathAsync( + LaunchContext context, + string hostPath, + bool forceDirectMode, + string? retryTag) + { + var resolution = new HostResolutionResult + { + Success = true, + ResolvedHostPath = Path.GetFullPath(hostPath), + ResolutionSource = "user_selected_path", + AppRoot = context.DeploymentLocator.GetAppRoot(), + ExplicitAppRoot = Path.GetDirectoryName(hostPath), + SearchedPaths = [Path.GetFullPath(hostPath)] + }; + + return LaunchWithResolvedPathAsync(context, resolution, forceDirectMode, retryTag); + } + + private static async Task LaunchWithResolvedPathAsync( + LaunchContext context, + HostResolutionResult resolution, + bool forceDirectMode, + string? retryTag) + { + var dataRoot = context.DataLocationResolver.ResolveDataRoot(); + var plan = HostLaunchPlanBuilder.Build(context.CommandContext, context.DeploymentLocator, resolution, dataRoot); + var prerequisiteFailure = ValidateDotNetRuntimePrerequisite(plan, resolution); + if (prerequisiteFailure is not null) + { + return HostLaunchOutcome.FromResult(prerequisiteFailure); + } + + var hostPath = plan.HostPath; + if (OperatingSystem.IsLinux() || OperatingSystem.IsMacOS()) + { + EnsureExecutable(hostPath); + } + + var primaryMode = HostStartMode.Direct; + var fallbackMode = !forceDirectMode && OperatingSystem.IsWindows() + ? HostStartMode.ShellExecute + : (HostStartMode?)null; + + var firstAttempt = await StartHostProcessAsync(plan, primaryMode, retryTag).ConfigureAwait(false); + if (firstAttempt.ProcessCreated && firstAttempt.Process is not null) + { + var firstDetails = BuildResolutionDetails(resolution, firstAttempt, null, null); + return HostLaunchOutcome.FromProcess( + firstAttempt.Process, + LaunchResultBuilder.Build(true, "launchHost", "ok", "Host launched.", firstDetails), + firstDetails); + } + + if (fallbackMode is null) + { + return BuildOutcomeFromAttempt(resolution, firstAttempt, null); + } + + Logger.Warn( + $"Primary host start attempt failed. Retrying with fallback mode '{fallbackMode}'. " + + $"FailureReason='{firstAttempt.FailureReason ?? "unknown"}'; ExitCode='{firstAttempt.ExitCode?.ToString() ?? ""}'."); + + var secondAttempt = await StartHostProcessAsync(plan, fallbackMode.Value, retryTag).ConfigureAwait(false); + if (secondAttempt.ProcessCreated && secondAttempt.Process is not null) + { + var details = BuildResolutionDetails(resolution, firstAttempt, secondAttempt, null); + return HostLaunchOutcome.FromProcess( + secondAttempt.Process, + LaunchResultBuilder.Build(true, "launchHost", "ok", "Host launched.", details), + details); + } + + return BuildOutcomeFromAttempt(resolution, secondAttempt, firstAttempt); + } + + private static HostLaunchOutcome BuildOutcomeFromAttempt( + HostResolutionResult resolution, + HostStartAttempt finalAttempt, + HostStartAttempt? previousAttempt) + { + var details = BuildResolutionDetails( + resolution, + previousAttempt ?? finalAttempt, + previousAttempt is null ? null : finalAttempt, + !finalAttempt.ProcessCreated + ? "start" + : finalAttempt.ExitCode is int finalExitCode && HostActivationPolicy.IsFailedActivationExitCode(finalExitCode) + ? "activation" + : "early-exit"); + + if (!finalAttempt.ProcessCreated) + { + return HostLaunchOutcome.FromResult(LaunchResultBuilder.Build( + false, + "launchHost", + "host_start_failed", + $"Failed to start host using start mode '{finalAttempt.StartMode}'.", + details)); + } + + if (finalAttempt.ExitCode is not null && HostActivationPolicy.IsSuccessfulActivationExitCode(finalAttempt.ExitCode.Value)) + { + return HostLaunchOutcome.FromImmediateResult(LaunchResultBuilder.Build( + true, + "launch", + "activation_redirected", + "Launcher activation was redirected to the existing desktop instance.", + details)); + } + + if (finalAttempt.ExitCode is not null && HostActivationPolicy.IsFailedActivationExitCode(finalAttempt.ExitCode.Value)) + { + return HostLaunchOutcome.FromResult(LaunchResultBuilder.Build( + false, + "launch", + "activation_failed", + $"Host activation handshake failed using start mode '{finalAttempt.StartMode}'.", + details)); + } + + return HostLaunchOutcome.FromResult(LaunchResultBuilder.Build( + false, + "launchHost", + "host_exited_early", + $"Host exited early using start mode '{finalAttempt.StartMode}'.", + details)); + } + + private static async Task StartHostProcessAsync( + HostLaunchPlan plan, + HostStartMode startMode, + string? retryTag) + { + var startInfo = new ProcessStartInfo + { + FileName = plan.HostPath, + WorkingDirectory = plan.WorkingDirectory, + UseShellExecute = startMode == HostStartMode.ShellExecute + }; + + if (startMode == HostStartMode.Direct) + { + foreach (var argument in plan.Arguments) + { + startInfo.ArgumentList.Add(argument); + } + + foreach (var pair in plan.EnvironmentVariables) + { + startInfo.EnvironmentVariables[pair.Key] = pair.Value; + } + } + else + { + startInfo.Arguments = HostLaunchPlanBuilder.FormatArgumentsForLog(plan.Arguments); + } + + try + { + var process = Process.Start(startInfo); + Logger.Info( + $"Host launch requested. Mode='{startMode}'; RetryTag='{retryTag ?? ""}'; Path='{plan.HostPath}'; " + + $"PackageRoot='{plan.PackageRoot}'; WorkingDir='{plan.WorkingDirectory}'; Pid={(process is null ? -1 : process.Id)}; " + + $"Args='{HostLaunchPlanBuilder.FormatArgumentsForLog(plan.Arguments)}'."); + + if (process is null) + { + return HostStartAttempt.StartFailed(startMode, "process_start_returned_null", plan); + } + + await Task.Yield(); + return HostStartAttempt.Started(startMode, process, plan); + } + catch (Exception ex) + { + Logger.Error($"Host start failed. Mode='{startMode}'.", ex); + return HostStartAttempt.StartFailed(startMode, ex.GetType().Name, plan); + } + } + + internal static Dictionary BuildResolutionDetails( + HostResolutionResult resolution, + HostStartAttempt? firstAttempt, + HostStartAttempt? secondAttempt, + string? failureStage) + { + var details = new Dictionary(StringComparer.OrdinalIgnoreCase) + { + ["resolvedAppRoot"] = resolution.AppRoot, + ["explicitAppRoot"] = resolution.ExplicitAppRoot ?? string.Empty, + ["resolvedHostPath"] = resolution.ResolvedHostPath ?? string.Empty, + ["resolutionSource"] = resolution.ResolutionSource ?? string.Empty, + ["devModeConfigIgnored"] = resolution.DevModeConfigIgnored.ToString(), + ["searchedPaths"] = string.Join(" | ", resolution.SearchedPaths), + ["failureStage"] = failureStage ?? string.Empty + }; + + if (firstAttempt is not null) + { + details["startMode"] = firstAttempt.StartMode.ToString(); + details["processCreated"] = firstAttempt.ProcessCreated.ToString(); + details["hostPid"] = firstAttempt.ProcessId?.ToString() ?? string.Empty; + details["packageRoot"] = firstAttempt.PackageRoot ?? string.Empty; + details["workingDirectory"] = firstAttempt.WorkingDirectory ?? string.Empty; + details["arguments"] = firstAttempt.Arguments ?? string.Empty; + details["firstAttemptFailureReason"] = firstAttempt.FailureReason ?? string.Empty; + details["firstAttemptExitCode"] = firstAttempt.ExitCode?.ToString() ?? string.Empty; + } + + if (secondAttempt is not null) + { + details["fallbackStartMode"] = secondAttempt.StartMode.ToString(); + details["fallbackProcessCreated"] = secondAttempt.ProcessCreated.ToString(); + details["fallbackHostPid"] = secondAttempt.ProcessId?.ToString() ?? string.Empty; + details["fallbackPackageRoot"] = secondAttempt.PackageRoot ?? string.Empty; + details["fallbackWorkingDirectory"] = secondAttempt.WorkingDirectory ?? string.Empty; + details["fallbackArguments"] = secondAttempt.Arguments ?? string.Empty; + details["fallbackFailureReason"] = secondAttempt.FailureReason ?? string.Empty; + details["fallbackExitCode"] = secondAttempt.ExitCode?.ToString() ?? string.Empty; + } + + return details; + } + + private static void EnsureExecutable(string path) + { + if (OperatingSystem.IsWindows()) + { + return; + } + + try + { + var mode = File.GetUnixFileMode(path); + mode |= UnixFileMode.UserExecute | UnixFileMode.GroupExecute | UnixFileMode.OtherExecute; + File.SetUnixFileMode(path, mode); + } + catch + { + } + } +} diff --git a/LanMountainDesktop.Launcher/Startup/LaunchAttemptDetails.cs b/LanMountainDesktop.Launcher/Startup/LaunchAttemptDetails.cs new file mode 100644 index 0000000..eba31ee --- /dev/null +++ b/LanMountainDesktop.Launcher/Startup/LaunchAttemptDetails.cs @@ -0,0 +1,49 @@ +using LanMountainDesktop.Launcher.Models; +using LanMountainDesktop.Shared.Contracts.Launcher; + +namespace LanMountainDesktop.Launcher.Startup; + +internal static class LaunchAttemptDetails +{ + public static Dictionary Build( + StartupAttemptRecord? trackedAttempt, + bool attachedToExistingAttempt, + bool ipcConnected, + bool hostProcessAlive, + StartupStage lastStage, + string lastStageMessage, + string? activationFailureReason, + bool softTimeoutShown, + bool recoveryActivationAttempted) + { + var details = new Dictionary(StringComparer.OrdinalIgnoreCase) + { + ["hostProcessAlive"] = hostProcessAlive.ToString(), + ["attachedToExistingAttempt"] = attachedToExistingAttempt.ToString(), + ["ipcConnected"] = ipcConnected.ToString(), + ["ipcStage"] = lastStage.ToString(), + ["ipcMessage"] = lastStageMessage, + ["activationFailureReason"] = activationFailureReason ?? string.Empty, + ["softTimeoutShown"] = softTimeoutShown.ToString(), + ["recoveryActivationAttempted"] = recoveryActivationAttempted.ToString() + }; + + if (trackedAttempt is not null) + { + details["startupAttemptId"] = trackedAttempt.AttemptId; + details["startupAttemptState"] = trackedAttempt.State.ToString(); + details["startupAttemptStartedAtUtc"] = trackedAttempt.StartedAtUtc.ToString("O"); + details["startupAttemptUpdatedAtUtc"] = trackedAttempt.UpdatedAtUtc.ToString("O"); + details["startupAttemptHeartbeatAtUtc"] = trackedAttempt.HeartbeatAtUtc.ToString("O"); + details["successPolicy"] = trackedAttempt.SuccessPolicy; + details["hostPid"] = trackedAttempt.HostPid.ToString(); + details["coordinatorPid"] = trackedAttempt.CoordinatorPid.ToString(); + details["coordinatorPipeName"] = trackedAttempt.CoordinatorPipeName; + details["reservedBeforeHostStart"] = trackedAttempt.ReservedBeforeHostStart.ToString(); + details["publicIpcConnected"] = trackedAttempt.PublicIpcConnected.ToString(); + details["shellStatus"] = trackedAttempt.ShellStatus; + } + + return details; + } +} diff --git a/LanMountainDesktop.Launcher/Startup/LaunchPipeline.cs b/LanMountainDesktop.Launcher/Startup/LaunchPipeline.cs new file mode 100644 index 0000000..1748c40 --- /dev/null +++ b/LanMountainDesktop.Launcher/Startup/LaunchPipeline.cs @@ -0,0 +1,191 @@ +using System.Diagnostics; +using LanMountainDesktop.Launcher.Models; +using LanMountainDesktop.Launcher.Views; +using LanMountainDesktop.Shared.Contracts.Launcher; +using LanMountainDesktop.Shared.IPC; +using LanMountainDesktop.Shared.IPC.Abstractions.Services; + +namespace LanMountainDesktop.Launcher.Startup; + +internal enum LaunchPhaseStatus +{ + Continue, + Completed +} + +internal sealed record LaunchPhaseResult(LaunchPhaseStatus Status, LauncherResult? Result = null); + +internal interface ILaunchPhase +{ + string Name { get; } + + Task ExecuteAsync(LaunchContext context, CancellationToken cancellationToken = default); +} + +internal sealed class LaunchContext +{ + public required CommandContext CommandContext { get; init; } + public required DeploymentLocator DeploymentLocator { get; init; } + public required OobeStateService OobeStateService { get; init; } + public required UpdateEngineService UpdateEngine { get; init; } + public required StartupAttemptRegistry StartupAttemptRegistry { get; init; } + public LauncherCoordinatorIpcServer? CoordinatorIpcServer { get; init; } + public required DataLocationResolver DataLocationResolver { get; init; } + public required IReadOnlyList OobeSteps { get; init; } + + public SplashWindow SplashWindow { get; set; } = null!; + public LoadingDetailsWindow? LoadingDetailsWindow { get; set; } + public ISplashStageReporter Reporter { get; set; } = null!; + public LanMountainDesktopIpcClient IpcClient { get; set; } = null!; + public StartupSuccessTracker SuccessTracker { get; set; } = null!; + public TaskCompletionSource SuccessTcs { get; set; } = null!; + public TaskCompletionSource ActivationFailedTcs { get; set; } = null!; + public LoadingStateMessage LoadingState { get; set; } + public Dictionary LauncherContextDetails { get; set; } = []; + public OobeLaunchDecision OobeDecision { get; set; } = null!; + + public StartupStage LastStage { get; set; } = StartupStage.Initializing; + public string LastStageMessage { get; set; } = "launcher-started"; + public string ActivationFailureReason { get; set; } = string.Empty; + public bool IpcConnected { get; set; } + public bool SoftTimeoutShown { get; set; } + public bool AttachedToExistingAttempt { get; set; } + public bool WindowsClosingByOrchestrator { get; set; } + public StartupAttemptRecord? TrackedAttempt { get; set; } + public PublicShellStatus? ShellStatus { get; set; } + public HostLaunchOutcome? LaunchOutcome { get; set; } + + public Action PublishCoordinatorStatus { get; set; } = static (_, _, _) => { }; + public EventHandler? SplashClosedHandler { get; set; } +} + +internal sealed class LaunchPipeline +{ + private readonly IReadOnlyList _phases; + + public LaunchPipeline(IEnumerable phases) + { + _phases = phases.ToList(); + } + + public async Task ExecuteAsync(LaunchContext context, CancellationToken cancellationToken = default) + { + foreach (var phase in _phases) + { + Logger.Info($"Launch pipeline entering phase '{phase.Name}'."); + var phaseResult = await phase.ExecuteAsync(context, cancellationToken).ConfigureAwait(false); + if (phaseResult.Status == LaunchPhaseStatus.Completed) + { + return phaseResult.Result ?? LaunchResultBuilder.BuildFailure( + "launch", + "phase_completed_without_result", + $"Launch phase '{phase.Name}' completed without a result."); + } + } + + return LaunchResultBuilder.BuildFailure( + "launch", + "pipeline_incomplete", + "Launch pipeline finished without producing a result."); + } +} + +internal static class LaunchResultBuilder +{ + public static LauncherResult Build( + bool success, + string stage, + string code, + string message, + Dictionary? details = null, + string? errorMessage = null) + { + Logger.Info($"Launcher result prepared. Success={success}; Stage='{stage}'; Code='{code}'."); + return new LauncherResult + { + Success = success, + Stage = stage, + Code = code, + Message = message, + ErrorMessage = errorMessage, + Details = details ?? [] + }; + } + + public static LauncherResult BuildFailure(string stage, string code, string message) => + Build(false, stage, code, message); + + public static LauncherResult WithAdditionalDetails(LauncherResult result, Dictionary details) => + new() + { + Success = result.Success, + Stage = result.Stage, + Code = result.Code, + Message = result.Message, + CurrentVersion = result.CurrentVersion, + TargetVersion = result.TargetVersion, + RolledBackTo = result.RolledBackTo, + Details = MergeDetails(details, result.Details), + InstalledPackagePath = result.InstalledPackagePath, + ManifestId = result.ManifestId, + ManifestName = result.ManifestName, + ErrorMessage = result.ErrorMessage + }; + + public static Dictionary BuildLauncherContextDetails( + CommandContext context, + OobeLaunchDecision oobeDecision, + string appRoot) => + new(StringComparer.OrdinalIgnoreCase) + { + ["command"] = context.Command, + ["launchSource"] = context.LaunchSource, + ["isGuiMode"] = context.IsGuiCommand.ToString(), + ["isDebugMode"] = context.IsDebugMode.ToString(), + ["isElevated"] = oobeDecision.IsElevated.ToString(), + ["resolvedAppRoot"] = appRoot, + ["oobeStatePath"] = oobeDecision.StatePath, + ["oobeStateStatus"] = oobeDecision.Status.ToString(), + ["oobeDecision"] = oobeDecision.ShouldShowOobe ? "show" : "skip", + ["oobeSuppressionReason"] = oobeDecision.SuppressionReason, + ["oobeResultCode"] = oobeDecision.ResultCode, + ["userSid"] = oobeDecision.UserSid ?? string.Empty, + ["usedLegacyOobeMarker"] = oobeDecision.UsedLegacyMarker.ToString(), + ["migratedLegacyOobeMarker"] = oobeDecision.MigratedLegacyMarker.ToString(), + ["oobeStateError"] = oobeDecision.ErrorMessage + }; + + public static Dictionary MergeDetails( + Dictionary left, + Dictionary right) + { + var merged = new Dictionary(left, StringComparer.OrdinalIgnoreCase); + foreach (var pair in right) + { + merged[pair.Key] = pair.Value; + } + + return merged; + } + + public static bool TryGetLiveProcess(int processId, out Process? process) + { + process = null; + if (processId <= 0) + { + return false; + } + + try + { + process = Process.GetProcessById(processId); + return !process.HasExited; + } + catch + { + process?.Dispose(); + process = null; + return false; + } + } +} diff --git a/LanMountainDesktop.Launcher/Startup/LaunchUiPresenter.cs b/LanMountainDesktop.Launcher/Startup/LaunchUiPresenter.cs new file mode 100644 index 0000000..bdf719b --- /dev/null +++ b/LanMountainDesktop.Launcher/Startup/LaunchUiPresenter.cs @@ -0,0 +1,174 @@ +using Avalonia.Threading; +using LanMountainDesktop.Launcher.Models; +using LanMountainDesktop.Launcher.Views; +using LanMountainDesktop.Shared.Contracts.Launcher; +using LanMountainDesktop.Shared.IPC.Abstractions.Services; + +namespace LanMountainDesktop.Launcher.Startup; + +internal static class LaunchUiPresenter +{ + public static async Task CloseWindowsAsync(SplashWindow splashWindow, LoadingDetailsWindow? loadingDetailsWindow) + { + try + { + await Dispatcher.UIThread.InvokeAsync(() => splashWindow.DismissAsync()); + } + catch (Exception ex) + { + Logger.Error("Failed to dismiss splash window.", ex); + } + + await Dispatcher.UIThread.InvokeAsync(() => + { + try + { + if (loadingDetailsWindow is not null && loadingDetailsWindow.IsVisible) + { + loadingDetailsWindow.Close(); + } + } + catch (Exception ex) + { + Logger.Error("Failed to close loading details window.", ex); + } + }); + } + + public static async Task<(ErrorWindowResult Result, string? CustomPath)> ShowHostNotFoundErrorAsync() + { + ErrorWindow? errorWindow = null; + + await Dispatcher.UIThread.InvokeAsync(() => + { + try + { + errorWindow = new ErrorWindow(); + errorWindow.ConfigureForHostNotFound(); + errorWindow.SetErrorMessage("LanMountainDesktop host executable was not found."); + errorWindow.Show(); + Logger.Warn("Host not found. Showing error window."); + } + catch (Exception ex) + { + Logger.Error("Failed to show host-not-found error window.", ex); + } + }); + + if (errorWindow is null) + { + return (ErrorWindowResult.Exit, null); + } + + ErrorWindowResult result; + string? customPath; + try + { + result = await errorWindow.WaitForChoiceAsync().ConfigureAwait(false); + customPath = errorWindow.GetCustomHostPath(); + Logger.Info($"Host-not-found window result='{result}'; HasCustomPath={!string.IsNullOrWhiteSpace(customPath)}."); + } + catch (Exception ex) + { + Logger.Error("Failed while waiting for host-not-found window result.", ex); + result = ErrorWindowResult.Exit; + customPath = null; + } + + await Dispatcher.UIThread.InvokeAsync(() => + { + try + { + if (errorWindow.IsVisible && errorWindow.IsLoaded) + { + errorWindow.Close(); + } + } + catch (Exception ex) + { + Logger.Error("Failed to close host-not-found error window.", ex); + } + }); + + return (result, customPath); + } + + public static async Task ShowMigrationPromptAsync(LegacyVersionInfo legacyInfo) + { + MigrationPromptWindow? migrationWindow = null; + + await Dispatcher.UIThread.InvokeAsync(() => + { + try + { + migrationWindow = new MigrationPromptWindow(); + migrationWindow.SetLegacyInfo(legacyInfo); + migrationWindow.Show(); + } + catch (Exception ex) + { + Logger.Error("Failed to show migration prompt window.", ex); + } + }); + + if (migrationWindow is null) + { + return MigrationResult.Skipped; + } + + MigrationResult result; + try + { + result = await migrationWindow.WaitForChoiceAsync().ConfigureAwait(false); + } + catch (Exception ex) + { + Logger.Error("Failed while waiting for migration prompt result.", ex); + result = MigrationResult.Skipped; + } + + await Dispatcher.UIThread.InvokeAsync(() => + { + try + { + if (migrationWindow.IsVisible && migrationWindow.IsLoaded) + { + migrationWindow.Close(); + } + } + catch (Exception ex) + { + Logger.Error("Failed to close migration prompt window.", ex); + } + }); + + return result; + } + + public static string MapStartupStageToSplashStage(StartupStage stage) => stage switch + { + StartupStage.Initializing => "initializing", + StartupStage.LoadingSettings => "settings", + StartupStage.LoadingPlugins => "plugins", + StartupStage.TrayReady => "shell", + StartupStage.InitializingUI => "ui", + StartupStage.ShellInitialized => "shell", + StartupStage.BackgroundReady => "ready", + StartupStage.DesktopVisible => "ready", + StartupStage.ActivationRedirected => "activation", + StartupStage.ActivationFailed => "error", + StartupStage.Ready => "ready", + _ => "launch" + }; + + public static async Task ShowMultiInstancePromptAsync(PublicShellStatus status) + { + return await Dispatcher.UIThread.InvokeAsync(async () => + { + var prompt = new MultiInstancePromptWindow(); + prompt.SetDetails(status.ProcessId, status.ShellState); + prompt.Show(); + return await prompt.WaitForChoiceAsync().ConfigureAwait(true); + }); + } +} diff --git a/LanMountainDesktop.Launcher/Startup/Phases/ApplyPendingUpdatePhase.cs b/LanMountainDesktop.Launcher/Startup/Phases/ApplyPendingUpdatePhase.cs new file mode 100644 index 0000000..703061f --- /dev/null +++ b/LanMountainDesktop.Launcher/Startup/Phases/ApplyPendingUpdatePhase.cs @@ -0,0 +1,27 @@ +namespace LanMountainDesktop.Launcher.Startup; + +internal sealed class ApplyPendingUpdatePhase : ILaunchPhase +{ + public string Name => nameof(ApplyPendingUpdatePhase); + + public async Task ExecuteAsync(LaunchContext context, CancellationToken cancellationToken = default) + { + context.Reporter.Report("update", "Checking updates..."); + var updateResult = await context.UpdateEngine.ApplyPendingUpdateAsync().ConfigureAwait(false); + if (!updateResult.Success) + { + Logger.Warn($"Update apply failed, will try to launch existing version. Error='{updateResult.Message}'."); + context.Reporter.Report("update", "Update failed, launching existing version..."); + try + { + context.UpdateEngine.CleanupIncomingArtifacts(); + } + catch (Exception ex) + { + Logger.Warn($"Failed to cleanup update artifacts after failed update: {ex.Message}"); + } + } + + return new LaunchPhaseResult(LaunchPhaseStatus.Continue); + } +} diff --git a/LanMountainDesktop.Launcher/Startup/Phases/CleanupDeploymentsPhase.cs b/LanMountainDesktop.Launcher/Startup/Phases/CleanupDeploymentsPhase.cs new file mode 100644 index 0000000..e1a4ca1 --- /dev/null +++ b/LanMountainDesktop.Launcher/Startup/Phases/CleanupDeploymentsPhase.cs @@ -0,0 +1,17 @@ +namespace LanMountainDesktop.Launcher.Startup; + +internal sealed class CleanupDeploymentsPhase : ILaunchPhase +{ + public string Name => nameof(CleanupDeploymentsPhase); + + public Task ExecuteAsync(LaunchContext context, CancellationToken cancellationToken = default) + { + context.DeploymentLocator.CleanupOldDeployments(minVersionsToKeep: 3); + context.OobeDecision = context.OobeStateService.Evaluate(context.CommandContext); + context.LauncherContextDetails = LaunchResultBuilder.BuildLauncherContextDetails( + context.CommandContext, + context.OobeDecision, + context.DeploymentLocator.GetAppRoot()); + return Task.FromResult(new LaunchPhaseResult(LaunchPhaseStatus.Continue)); + } +} diff --git a/LanMountainDesktop.Launcher/Startup/Phases/ExistingHostProbePhase.cs b/LanMountainDesktop.Launcher/Startup/Phases/ExistingHostProbePhase.cs new file mode 100644 index 0000000..5b7a53e --- /dev/null +++ b/LanMountainDesktop.Launcher/Startup/Phases/ExistingHostProbePhase.cs @@ -0,0 +1,71 @@ +using LanMountainDesktop.Shared.Contracts.Launcher; + +namespace LanMountainDesktop.Launcher.Startup; + +internal sealed class ExistingHostProbePhase : ILaunchPhase +{ + public string Name => nameof(ExistingHostProbePhase); + + public async Task ExecuteAsync(LaunchContext context, CancellationToken cancellationToken = default) + { + if (!HostActivationPolicy.ShouldProbeExistingHostBeforeLaunch(context.CommandContext)) + { + return new LaunchPhaseResult(LaunchPhaseStatus.Continue); + } + + var multiInstanceBehavior = ExistingHostProbe.LoadMultiInstanceLaunchBehavior(context.DataLocationResolver); + var existingShellStatus = await ExistingHostProbe.TryGetExistingHostStatusAsync( + context.IpcClient, + StartupTimeoutPolicy.ExistingHostProbeTimeout).ConfigureAwait(false); + + if (!HostActivationPolicy.IsExistingHostReadyForLauncherDecision(existingShellStatus)) + { + return new LaunchPhaseResult(LaunchPhaseStatus.Continue); + } + + context.IpcConnected = true; + context.ShellStatus = existingShellStatus; + var decisionResult = await ExistingHostProbe.ApplyExistingHostBehaviorAsync( + context.IpcClient, + multiInstanceBehavior, + existingShellStatus!).ConfigureAwait(false); + context.ShellStatus = decisionResult.ActivationResult?.Status ?? existingShellStatus; + var recoverableActivationFailure = decisionResult.ActivationResult is not null && + HostActivationPolicy.IsRecoverableActivationFailure(decisionResult.ActivationResult); + context.LastStage = decisionResult.Success || recoverableActivationFailure + ? StartupStage.ActivationRedirected + : StartupStage.ActivationFailed; + context.LastStageMessage = decisionResult.Message; + if (decisionResult.Success || recoverableActivationFailure) + { + context.StartupAttemptRegistry.MarkOwnedSucceeded(context.LastStage, context.LastStageMessage); + } + else + { + context.StartupAttemptRegistry.MarkOwnedFailed(context.LastStage, context.LastStageMessage); + } + + context.PublishCoordinatorStatus(true, true, decisionResult.Success); + context.WindowsClosingByOrchestrator = true; + await LaunchUiPresenter.CloseWindowsAsync(context.SplashWindow, context.LoadingDetailsWindow).ConfigureAwait(false); + return new LaunchPhaseResult( + LaunchPhaseStatus.Completed, + LaunchResultBuilder.Build( + decisionResult.Success, + "launch", + decisionResult.Code, + decisionResult.Message, + LaunchResultBuilder.MergeDetails( + context.LauncherContextDetails, + new Dictionary(StringComparer.OrdinalIgnoreCase) + { + ["publicIpcConnected"] = "true", + ["multiInstanceBehavior"] = multiInstanceBehavior.ToString(), + ["existingHostPid"] = context.ShellStatus?.ProcessId.ToString() ?? string.Empty, + ["existingShellState"] = context.ShellStatus?.ShellState ?? string.Empty, + ["existingTrayState"] = context.ShellStatus?.Tray.State ?? string.Empty, + ["existingTaskbarUsable"] = context.ShellStatus?.Taskbar.IsUsable.ToString() ?? string.Empty, + ["activationAccepted"] = decisionResult.ActivationResult?.Accepted.ToString() ?? string.Empty + }))); + } +} diff --git a/LanMountainDesktop.Launcher/Startup/Phases/LaunchHostPhase.cs b/LanMountainDesktop.Launcher/Startup/Phases/LaunchHostPhase.cs new file mode 100644 index 0000000..7c264ef --- /dev/null +++ b/LanMountainDesktop.Launcher/Startup/Phases/LaunchHostPhase.cs @@ -0,0 +1,173 @@ +using LanMountainDesktop.Launcher.Models; +using LanMountainDesktop.Shared.Contracts.Launcher; + +namespace LanMountainDesktop.Launcher.Startup; + +internal sealed class LaunchHostPhase : ILaunchPhase +{ + private static readonly string SoftTimeoutStatusMessage = Resources.Strings.Coordinator_SlowDeviceMessage; + private static readonly string SoftTimeoutDetailsMessage = Resources.Strings.Coordinator_RunningHostMessage; + + private readonly HostLaunchService _hostLaunchService = new(); + + public string Name => nameof(LaunchHostPhase); + + public async Task ExecuteAsync(LaunchContext context, CancellationToken cancellationToken = default) + { + context.Reporter.Report("launch", "Launching desktop..."); + var startupSuccessTracker = context.SuccessTracker; + var attachableAttempt = context.StartupAttemptRegistry.TryGetAttachableAttempt( + context.CommandContext.LaunchSource, + startupSuccessTracker.PolicyKey); + + HostLaunchOutcome launchOutcome; + if (attachableAttempt is not null && + context.StartupAttemptRegistry.AdoptAttempt(attachableAttempt.AttemptId) && + LaunchResultBuilder.TryGetLiveProcess(attachableAttempt.HostPid, out var attachedProcess)) + { + context.TrackedAttempt = attachableAttempt; + context.AttachedToExistingAttempt = true; + context.IpcConnected = attachableAttempt.IpcConnected; + context.LastStage = attachableAttempt.LastObservedStage; + context.LastStageMessage = string.IsNullOrWhiteSpace(attachableAttempt.LastObservedMessage) + ? "Attached to the existing startup attempt." + : attachableAttempt.LastObservedMessage; + context.Reporter.Report( + LaunchUiPresenter.MapStartupStageToSplashStage(context.LastStage), + context.LastStageMessage); + context.PublishCoordinatorStatus(true, false, false); + + if (startupSuccessTracker.TryResolve(context.LastStage, out var attachedSuccessState)) + { + context.WindowsClosingByOrchestrator = true; + context.StartupAttemptRegistry.MarkOwnedSucceeded(attachedSuccessState.Stage, attachedSuccessState.Message); + await LaunchUiPresenter.CloseWindowsAsync(context.SplashWindow, context.LoadingDetailsWindow).ConfigureAwait(false); + return new LaunchPhaseResult( + LaunchPhaseStatus.Completed, + LaunchResultBuilder.Build( + true, + "launch", + attachedSuccessState.Code, + attachedSuccessState.Message, + LaunchResultBuilder.MergeDetails( + context.LauncherContextDetails, + LaunchAttemptDetails.Build( + context.TrackedAttempt, + context.AttachedToExistingAttempt, + context.IpcConnected, + hostProcessAlive: true, + context.LastStage, + context.LastStageMessage, + context.ActivationFailureReason, + softTimeoutShown: false, + recoveryActivationAttempted: false)))); + } + + if (attachableAttempt.State is StartupAttemptState.SoftTimeout or StartupAttemptState.DetachedWaiting) + { + context.SoftTimeoutShown = true; + context.Reporter.Report("delayed", SoftTimeoutStatusMessage); + context.LoadingState = HostStartupMonitor.BuildDelayedLoadingState( + context.LoadingState, + SoftTimeoutStatusMessage, + SoftTimeoutDetailsMessage, + context.TrackedAttempt!.StartedAtUtc); + context.LoadingDetailsWindow?.UpdateLoadingState(context.LoadingState); + } + + launchOutcome = HostLaunchOutcome.FromProcess( + attachedProcess!, + LaunchResultBuilder.Build( + true, + "launchHost", + "attached_attempt", + "Attached to an existing startup attempt.", + LaunchAttemptDetails.Build( + context.TrackedAttempt, + context.AttachedToExistingAttempt, + context.IpcConnected, + hostProcessAlive: true, + context.LastStage, + context.LastStageMessage, + context.ActivationFailureReason, + context.SoftTimeoutShown, + recoveryActivationAttempted: false)), + LaunchAttemptDetails.Build( + context.TrackedAttempt, + context.AttachedToExistingAttempt, + context.IpcConnected, + hostProcessAlive: true, + context.LastStage, + context.LastStageMessage, + context.ActivationFailureReason, + context.SoftTimeoutShown, + recoveryActivationAttempted: false)); + } + else + { + launchOutcome = await _hostLaunchService.LaunchAsync(context).ConfigureAwait(false); + } + + context.LaunchOutcome = launchOutcome; + + if (!launchOutcome.Result.Success) + { + return new LaunchPhaseResult( + LaunchPhaseStatus.Completed, + LaunchResultBuilder.WithAdditionalDetails(launchOutcome.Result, context.LauncherContextDetails)); + } + + if (launchOutcome.ImmediateResult is not null) + { + context.WindowsClosingByOrchestrator = true; + await LaunchUiPresenter.CloseWindowsAsync(context.SplashWindow, context.LoadingDetailsWindow).ConfigureAwait(false); + return new LaunchPhaseResult( + LaunchPhaseStatus.Completed, + LaunchResultBuilder.WithAdditionalDetails(launchOutcome.ImmediateResult, context.LauncherContextDetails)); + } + + if (launchOutcome.Process is null) + { + return new LaunchPhaseResult( + LaunchPhaseStatus.Completed, + LaunchResultBuilder.Build( + success: false, + stage: "launch", + code: "host_start_failed", + message: "Host launch did not create a process.", + details: LaunchResultBuilder.MergeDetails( + context.LauncherContextDetails, + LaunchResultBuilder.MergeDetails( + launchOutcome.Details, + LaunchAttemptDetails.Build( + context.TrackedAttempt, + context.AttachedToExistingAttempt, + context.IpcConnected, + hostProcessAlive: false, + context.LastStage, + context.LastStageMessage, + context.ActivationFailureReason, + context.SoftTimeoutShown, + recoveryActivationAttempted: false))))); + } + + if (!context.AttachedToExistingAttempt) + { + var reservedAttempt = context.StartupAttemptRegistry.GetOwnedAttempt(); + context.TrackedAttempt = reservedAttempt is { ReservedBeforeHostStart: true } + ? context.StartupAttemptRegistry.AssignOwnedHostProcess( + launchOutcome.Process.Id, + context.LastStage, + context.LastStageMessage) + : context.StartupAttemptRegistry.StartOwnedAttempt( + launchOutcome.Process.Id, + context.CommandContext.LaunchSource, + startupSuccessTracker.PolicyKey, + context.LastStage, + context.LastStageMessage); + context.PublishCoordinatorStatus(true, false, false); + } + + return new LaunchPhaseResult(LaunchPhaseStatus.Continue); + } +} diff --git a/LanMountainDesktop.Launcher/Startup/Phases/MonitorStartupPhase.cs b/LanMountainDesktop.Launcher/Startup/Phases/MonitorStartupPhase.cs new file mode 100644 index 0000000..9d97811 --- /dev/null +++ b/LanMountainDesktop.Launcher/Startup/Phases/MonitorStartupPhase.cs @@ -0,0 +1,68 @@ +namespace LanMountainDesktop.Launcher.Startup; + +internal sealed class MonitorStartupPhase : ILaunchPhase +{ + public string Name => nameof(MonitorStartupPhase); + + public async Task ExecuteAsync(LaunchContext context, CancellationToken cancellationToken = default) + { + var launchOutcome = context.LaunchOutcome + ?? throw new InvalidOperationException("LaunchHostPhase must run before MonitorStartupPhase."); + + if (launchOutcome.Process is null) + { + return new LaunchPhaseResult( + LaunchPhaseStatus.Completed, + LaunchResultBuilder.BuildFailure("launch", "host_start_failed", "Host process is missing.")); + } + + Dictionary ComposeLaunchDetails(bool hostProcessAlive, bool recoveryActivationAttempted = false) => + LaunchResultBuilder.MergeDetails( + context.LauncherContextDetails, + LaunchResultBuilder.MergeDetails( + launchOutcome.Details, + LaunchAttemptDetails.Build( + context.TrackedAttempt, + context.AttachedToExistingAttempt, + context.IpcConnected, + hostProcessAlive, + context.LastStage, + context.LastStageMessage, + context.ActivationFailureReason, + context.SoftTimeoutShown, + recoveryActivationAttempted))); + + var monitor = new HostStartupMonitor(); + var monitorOutcome = await monitor.MonitorUntilCompleteAsync(new HostStartupMonitor.Request( + launchOutcome.Process, + context.IpcClient, + context.SuccessTracker, + context.StartupAttemptRegistry, + context.TrackedAttempt, + context.AttachedToExistingAttempt, + context.LauncherContextDetails, + context.SuccessTcs, + context.ActivationFailedTcs, + context.Reporter, + context.LoadingDetailsWindow, + context.LoadingState, + context.LastStage, + context.LastStageMessage, + context.IpcConnected, + context.ActivationFailureReason, + context.SoftTimeoutShown, + context.PublishCoordinatorStatus, + ComposeLaunchDetails)).ConfigureAwait(false); + + context.WindowsClosingByOrchestrator = true; + await LaunchUiPresenter.CloseWindowsAsync(context.SplashWindow, context.LoadingDetailsWindow).ConfigureAwait(false); + return new LaunchPhaseResult( + LaunchPhaseStatus.Completed, + LaunchResultBuilder.Build( + monitorOutcome.Success, + "launch", + monitorOutcome.Code, + monitorOutcome.Message, + monitorOutcome.Details)); + } +} diff --git a/LanMountainDesktop.Launcher/Startup/Phases/OobeGatePhase.cs b/LanMountainDesktop.Launcher/Startup/Phases/OobeGatePhase.cs new file mode 100644 index 0000000..eff7803 --- /dev/null +++ b/LanMountainDesktop.Launcher/Startup/Phases/OobeGatePhase.cs @@ -0,0 +1,24 @@ +using Avalonia.Threading; + +namespace LanMountainDesktop.Launcher.Startup; + +internal sealed class OobeGatePhase : ILaunchPhase +{ + public string Name => nameof(OobeGatePhase); + + public async Task ExecuteAsync(LaunchContext context, CancellationToken cancellationToken = default) + { + if (context.OobeDecision.ShouldShowOobe) + { + await Dispatcher.UIThread.InvokeAsync(() => context.SplashWindow.Hide()); + foreach (var step in context.OobeSteps) + { + await step.RunAsync(cancellationToken).ConfigureAwait(false); + } + + await Dispatcher.UIThread.InvokeAsync(() => context.SplashWindow.Show()); + } + + return new LaunchPhaseResult(LaunchPhaseStatus.Continue); + } +} diff --git a/LanMountainDesktop.Tests/LauncherAirAppLifecycleServiceTests.cs b/LanMountainDesktop.Tests/LauncherAirAppLifecycleServiceTests.cs index b88e182..9f3163e 100644 --- a/LanMountainDesktop.Tests/LauncherAirAppLifecycleServiceTests.cs +++ b/LanMountainDesktop.Tests/LauncherAirAppLifecycleServiceTests.cs @@ -2,6 +2,7 @@ using System.Diagnostics; using LanMountainDesktop.ComponentSystem; using LanMountainDesktop.Launcher; using LanMountainDesktop.Launcher.AirApp; +using LanMountainDesktop.Launcher.Shell.EntryHandlers; using LanMountainDesktop.Shared.IPC.Abstractions.Services; using Xunit; @@ -115,7 +116,7 @@ public sealed class LauncherAirAppLifecycleServiceTests { var service = new LauncherAirAppLifecycleService(new TestAirAppProcessStarter(null)); - Assert.True(LanMountainDesktop.Launcher.App.ShouldKeepAirAppBrokerAlive(Environment.ProcessId, service)); + Assert.True(AirAppBrokerEntryHandler.ShouldKeepAirAppBrokerAlive(Environment.ProcessId, service)); } [Fact] @@ -123,7 +124,7 @@ public sealed class LauncherAirAppLifecycleServiceTests { var service = new LauncherAirAppLifecycleService(new TestAirAppProcessStarter(null)); - Assert.False(LanMountainDesktop.Launcher.App.ShouldKeepAirAppBrokerAlive(int.MaxValue, service)); + Assert.False(AirAppBrokerEntryHandler.ShouldKeepAirAppBrokerAlive(int.MaxValue, service)); } [Fact] @@ -141,7 +142,7 @@ public sealed class LauncherAirAppLifecycleServiceTests BuiltInComponentIds.DesktopWorldClock, "clock-2")); - Assert.True(LanMountainDesktop.Launcher.App.ShouldKeepAirAppBrokerAlive(int.MaxValue, service)); + Assert.True(AirAppBrokerEntryHandler.ShouldKeepAirAppBrokerAlive(int.MaxValue, service)); } [Fact] diff --git a/LanMountainDesktop.Tests/LauncherGlobalUsings.cs b/LanMountainDesktop.Tests/LauncherGlobalUsings.cs index e25d9dd..9f1d1d0 100644 --- a/LanMountainDesktop.Tests/LauncherGlobalUsings.cs +++ b/LanMountainDesktop.Tests/LauncherGlobalUsings.cs @@ -3,4 +3,5 @@ global using LanMountainDesktop.Launcher.Deployment; global using LanMountainDesktop.Launcher.Infrastructure; global using LanMountainDesktop.Launcher.Ipc; global using LanMountainDesktop.Launcher.Oobe; -global using LanMountainDesktop.Launcher.Update; +global using LanMountainDesktop.Launcher.Startup; +global using LanMountainDesktop.Launcher.Update; \ No newline at end of file