From 1ef47c780bea380088d2615e8f4ec7d478ca5aa5 Mon Sep 17 00:00:00 2001 From: lincube Date: Thu, 28 May 2026 11:13:14 +0800 Subject: [PATCH] refactor(launcher): add DI, IUpdateEngine facade, and architecture tests Co-authored-by: Cursor --- LanMountainDesktop.Launcher/GlobalUsings.cs | 1 - .../Infrastructure/Commands.cs | 8 +- .../LanMountainDesktop.Launcher.csproj | 1 + LanMountainDesktop.Launcher/Program.cs | 2 + ...ncherFlowCoordinator.HostStartupMonitor.cs | 340 ---------- ...ncherFlowCoordinator.LaunchOrchestrator.cs | 316 --------- .../LauncherFlowCoordinator.UiPresenter.cs | 174 ----- .../Services/LauncherFlowCoordinator.cs | 606 ------------------ .../Shell/LauncherCompositionRoot.cs | 15 +- .../Shell/LauncherOrchestrator.cs | 9 +- .../Shell/LauncherServiceRegistration.cs | 55 ++ .../Startup/LaunchPipeline.cs | 2 +- .../Update/IUpdateEngine.cs | 18 + ...EngineService.cs => UpdateEngineFacade.cs} | 6 +- .../Views/DataLocationPromptWindow.axaml.cs | 2 +- .../Views/DevDebugWindow.axaml.cs | 2 +- .../Views/ErrorWindow.axaml.cs | 2 +- .../Views/LoadingDetailsWindow.axaml.cs | 2 +- .../Views/MigrationPromptWindow.axaml.cs | 2 +- .../Views/OobeWindow.axaml.cs | 2 +- .../Views/SplashWindow.axaml.cs | 2 +- .../DeploymentLocatorTests.cs | 1 - .../DotNetRuntimeProbeTests.cs | 3 +- .../HostAppSettingsOobeMergerTests.cs | 1 - .../HostLaunchPlanBuilderTests.cs | 1 - .../LauncherArchitectureTests.cs | 41 ++ .../LauncherCoordinatorRegistryTests.cs | 1 - .../LauncherDebugSettingsStoreTests.cs | 1 - .../OobeStateServiceTests.cs | 1 - .../UpdateSystemRegressionTests.cs | 15 +- docs/LAUNCHER.md | 46 +- 31 files changed, 167 insertions(+), 1511 deletions(-) delete mode 100644 LanMountainDesktop.Launcher/Services/LauncherFlowCoordinator.HostStartupMonitor.cs delete mode 100644 LanMountainDesktop.Launcher/Services/LauncherFlowCoordinator.LaunchOrchestrator.cs delete mode 100644 LanMountainDesktop.Launcher/Services/LauncherFlowCoordinator.UiPresenter.cs delete mode 100644 LanMountainDesktop.Launcher/Services/LauncherFlowCoordinator.cs create mode 100644 LanMountainDesktop.Launcher/Shell/LauncherServiceRegistration.cs create mode 100644 LanMountainDesktop.Launcher/Update/IUpdateEngine.cs rename LanMountainDesktop.Launcher/Update/{UpdateEngineService.cs => UpdateEngineFacade.cs} (99%) create mode 100644 LanMountainDesktop.Tests/LauncherArchitectureTests.cs diff --git a/LanMountainDesktop.Launcher/GlobalUsings.cs b/LanMountainDesktop.Launcher/GlobalUsings.cs index b30d6bc..5292e5d 100644 --- a/LanMountainDesktop.Launcher/GlobalUsings.cs +++ b/LanMountainDesktop.Launcher/GlobalUsings.cs @@ -6,4 +6,3 @@ global using LanMountainDesktop.Launcher.Oobe; global using LanMountainDesktop.Launcher.Plugins; global using LanMountainDesktop.Launcher.Startup; global using LanMountainDesktop.Launcher.Update; -global using LanMountainDesktop.Launcher.Services; diff --git a/LanMountainDesktop.Launcher/Infrastructure/Commands.cs b/LanMountainDesktop.Launcher/Infrastructure/Commands.cs index a348e55..f3ae780 100644 --- a/LanMountainDesktop.Launcher/Infrastructure/Commands.cs +++ b/LanMountainDesktop.Launcher/Infrastructure/Commands.cs @@ -36,7 +36,7 @@ internal static class Commands { var appRoot = ResolveAppRoot(context); var deploymentLocator = new DeploymentLocator(appRoot); - var updateEngine = new UpdateEngineService(deploymentLocator); + var updateEngine = new UpdateEngineFacade(deploymentLocator); var pluginInstaller = new PluginInstallerService(); var pluginUpgrades = new PluginUpgradeQueueService(pluginInstaller); @@ -63,7 +63,7 @@ internal static class Commands private static async Task ExecuteCoreAsync( CommandContext context, - UpdateEngineService updateEngine, + UpdateEngineFacade updateEngine, PluginInstallerService pluginInstaller, PluginUpgradeQueueService pluginUpgrades) { @@ -84,7 +84,7 @@ internal static class Commands } } - private static async Task ExecuteUpdateAsync(CommandContext context, UpdateEngineService updateEngine) + private static async Task ExecuteUpdateAsync(CommandContext context, UpdateEngineFacade updateEngine) { return context.SubCommand.ToLowerInvariant() switch { @@ -102,7 +102,7 @@ internal static class Commands }; } - private static async Task DownloadUpdatePayloadAsync(CommandContext context, UpdateEngineService updateEngine) + private static async Task DownloadUpdatePayloadAsync(CommandContext context, UpdateEngineFacade updateEngine) { return await updateEngine.DownloadAsync( context.GetOption("manifest-url") ?? throw new InvalidOperationException("Missing --manifest-url."), diff --git a/LanMountainDesktop.Launcher/LanMountainDesktop.Launcher.csproj b/LanMountainDesktop.Launcher/LanMountainDesktop.Launcher.csproj index 3e77576..b9c4303 100644 --- a/LanMountainDesktop.Launcher/LanMountainDesktop.Launcher.csproj +++ b/LanMountainDesktop.Launcher/LanMountainDesktop.Launcher.csproj @@ -30,6 +30,7 @@ + diff --git a/LanMountainDesktop.Launcher/Program.cs b/LanMountainDesktop.Launcher/Program.cs index fc6eb5f..426e17f 100644 --- a/LanMountainDesktop.Launcher/Program.cs +++ b/LanMountainDesktop.Launcher/Program.cs @@ -1,5 +1,6 @@ using Avalonia; using LanMountainDesktop.Launcher.Models; +using LanMountainDesktop.Launcher.Shell; namespace LanMountainDesktop.Launcher; public static class Program @@ -32,6 +33,7 @@ public static class Program } LauncherRuntimeContext.Current = commandContext; + LauncherServiceRegistration.Initialize(commandContext); var appRoot = Commands.ResolveAppRoot(commandContext); var languageCode = LanguagePreferenceService.ResolveLanguageCode(appRoot); diff --git a/LanMountainDesktop.Launcher/Services/LauncherFlowCoordinator.HostStartupMonitor.cs b/LanMountainDesktop.Launcher/Services/LauncherFlowCoordinator.HostStartupMonitor.cs deleted file mode 100644 index d1e72fb..0000000 --- a/LanMountainDesktop.Launcher/Services/LauncherFlowCoordinator.HostStartupMonitor.cs +++ /dev/null @@ -1,340 +0,0 @@ -using System.Diagnostics; -using Avalonia.Threading; -using LanMountainDesktop.Launcher.Models; -using LanMountainDesktop.Launcher.Resources; -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.Services; - -internal sealed partial class LauncherFlowCoordinator -{ - private MultiInstanceLaunchBehavior LoadMultiInstanceLaunchBehavior() - { - 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; - } - } - - private 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(); - var status = await shellProxy.GetShellStatusAsync().ConfigureAwait(false); - StartupDiagnostics.TraceShellStatus("existing_host_probe", status); - return status; - } - catch (Exception ex) - { - Logger.Info($"Existing host status probe did not complete: {ex.Message}"); - return null; - } - } - - private 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 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 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); - } - - private static async Task TryActivateExistingHostWithStatusAsync( - 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.ActivateMainWindowWithStatusAsync().ConfigureAwait(false); - } - catch (Exception ex) - { - Logger.Info($"Existing host activation probe did not complete: {ex.Message}"); - return null; - } - } - - private static async Task TryGetPublicShellStatusAsync( - LanMountainDesktopIpcClient ipcClient) => - await HostStartupMonitor.TryGetPublicShellStatusAsync(ipcClient).ConfigureAwait(false); - - private static async Task TryRecoverActivationThroughExistingHostAsync( - LanMountainDesktopIpcClient ipcClient, - StartupSuccessTracker startupSuccessTracker, - TimeSpan timeout) => - await HostStartupMonitor.TryRecoverActivationThroughExistingHostAsync( - ipcClient, - startupSuccessTracker, - timeout).ConfigureAwait(false); - - private static Dictionary BuildAttemptDetails( - 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; - } - - private 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; - } - } - - private enum HostStartMode - { - ShellExecute, - Direct - } - - private 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)); - } - - private sealed record ExistingHostBehaviorResult( - bool Success, - string Code, - string Message, - PublicShellActivationResult? ActivationResult); - - private 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/Services/LauncherFlowCoordinator.LaunchOrchestrator.cs b/LanMountainDesktop.Launcher/Services/LauncherFlowCoordinator.LaunchOrchestrator.cs deleted file mode 100644 index 5ab0eb5..0000000 --- a/LanMountainDesktop.Launcher/Services/LauncherFlowCoordinator.LaunchOrchestrator.cs +++ /dev/null @@ -1,316 +0,0 @@ -using System.Diagnostics; -using Avalonia.Threading; -using LanMountainDesktop.Launcher.Models; -using LanMountainDesktop.Launcher.Resources; -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.Services; - -internal sealed partial class LauncherFlowCoordinator -{ - private async Task LaunchHostWithIpcAsync(bool forceDirectMode = false, string? retryTag = null) - { - var resolution = _deploymentLocator.ResolveHostExecutable(_context); - if (!resolution.Success || string.IsNullOrWhiteSpace(resolution.ResolvedHostPath)) - { - var (errorResult, selectedPath) = await ShowHostNotFoundErrorAsync().ConfigureAwait(false); - if (errorResult == ErrorWindowResult.Retry) - { - if (!string.IsNullOrWhiteSpace(selectedPath) && File.Exists(selectedPath)) - { - return await LaunchHostWithExplicitPathAsync(selectedPath, forceDirectMode, retryTag).ConfigureAwait(false); - } - - return await LaunchHostWithIpcAsync(forceDirectMode, retryTag).ConfigureAwait(false); - } - - return HostLaunchOutcome.FromResult(BuildResult( - success: false, - stage: "launchHost", - code: "host_not_found", - message: "LanMountainDesktop host executable was not found.", - details: BuildResolutionDetails(resolution, null, null, "resolve"))); - } - - return await LaunchHostWithResolvedPathAsync(resolution, forceDirectMode, retryTag).ConfigureAwait(false); - } - - private Task LaunchHostWithExplicitPathAsync(string hostPath, bool forceDirectMode, string? retryTag) - { - var resolution = new HostResolutionResult - { - Success = true, - ResolvedHostPath = Path.GetFullPath(hostPath), - ResolutionSource = "user_selected_path", - AppRoot = _deploymentLocator.GetAppRoot(), - ExplicitAppRoot = Path.GetDirectoryName(hostPath), - SearchedPaths = [Path.GetFullPath(hostPath)] - }; - - return LaunchHostWithResolvedPathAsync(resolution, forceDirectMode, retryTag); - } - - internal 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 BuildResult( - 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 async Task LaunchHostWithResolvedPathAsync( - HostResolutionResult resolution, - bool forceDirectMode, - string? retryTag) - { - var dataRoot = _dataLocationResolver.ResolveDataRoot(); - var plan = HostLaunchPlanBuilder.Build(_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, - BuildResult(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, - BuildResult(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(BuildResult( - 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(BuildResult( - 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(BuildResult( - false, - "launch", - "activation_failed", - $"Host activation handshake failed using start mode '{finalAttempt.StartMode}'.", - details)); - } - - return HostLaunchOutcome.FromResult(BuildResult( - false, - "launchHost", - "host_exited_early", - $"Host exited early using start mode '{finalAttempt.StartMode}'.", - details)); - } - - private 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); - } - } - - private 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 - { - } - } -} \ No newline at end of file diff --git a/LanMountainDesktop.Launcher/Services/LauncherFlowCoordinator.UiPresenter.cs b/LanMountainDesktop.Launcher/Services/LauncherFlowCoordinator.UiPresenter.cs deleted file mode 100644 index 22c81a7..0000000 --- a/LanMountainDesktop.Launcher/Services/LauncherFlowCoordinator.UiPresenter.cs +++ /dev/null @@ -1,174 +0,0 @@ -using System.Diagnostics; -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.Services; - -internal sealed partial class LauncherFlowCoordinator -{ - private 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); - } - }); - } - private 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); - } - - private 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; - } - private 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" - }; - private 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); - }); - } -} \ No newline at end of file diff --git a/LanMountainDesktop.Launcher/Services/LauncherFlowCoordinator.cs b/LanMountainDesktop.Launcher/Services/LauncherFlowCoordinator.cs deleted file mode 100644 index 85f74f3..0000000 --- a/LanMountainDesktop.Launcher/Services/LauncherFlowCoordinator.cs +++ /dev/null @@ -1,606 +0,0 @@ -using System.Diagnostics; -using Avalonia.Threading; -using LanMountainDesktop.Launcher.Models; -using LanMountainDesktop.Launcher.Resources; -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.Services; - -internal sealed partial class LauncherFlowCoordinator -{ - private static readonly string SoftTimeoutStatusMessage = Strings.Coordinator_SlowDeviceMessage; - private static readonly string SoftTimeoutDetailsMessage = Strings.Coordinator_RunningHostMessage; - - 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; - - public LauncherFlowCoordinator( - CommandContext context, - DeploymentLocator deploymentLocator, - OobeStateService oobeStateService, - UpdateEngineService updateEngine, - StartupAttemptRegistry? startupAttemptRegistry = null, - LauncherCoordinatorIpcServer? coordinatorIpcServer = null) - { - _context = context; - _deploymentLocator = deploymentLocator; - _oobeStateService = oobeStateService; - _updateEngine = updateEngine; - _startupAttemptRegistry = startupAttemptRegistry ?? new StartupAttemptRegistry(); - _coordinatorIpcServer = coordinatorIpcServer; - _dataLocationResolver = new DataLocationResolver(deploymentLocator.GetAppRoot()); - _oobeSteps = - [ - new WelcomeOobeStep(_oobeStateService, _context), - new DataLocationOobeStep(_dataLocationResolver) - ]; - } - - public static string ResolveSuccessPolicyKey(CommandContext context) - { - return new StartupSuccessTracker(context).PolicyKey; - } - - public async Task RunAsync(SplashWindow? existingSplashWindow = null) - { - try - { - _deploymentLocator.CleanupOldDeployments(minVersionsToKeep: 3); - var oobeDecision = _oobeStateService.Evaluate(_context); - var launcherContextDetails = BuildLauncherContextDetails(_context, oobeDecision, _deploymentLocator.GetAppRoot()); - - if (oobeDecision.ShouldShowOobe) - { - var legacyInfo = LegacyVersionDetector.DetectLegacyInstallation(); - if (legacyInfo is not null) - { - var migrationResult = await 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 windowsClosingByCoordinator = false; - 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; - StartupAttemptRecord? trackedAttempt = null; - PublicShellStatus? shellStatus = null; - - 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 && 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(); - - var loadingState = new LoadingStateMessage(); - EventHandler? splashClosedHandler = null; - splashClosedHandler = (_, _) => - { - if (windowsClosingByCoordinator) - { - 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(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); - } - }); - }); - - try - { - if (HostActivationPolicy.ShouldProbeExistingHostBeforeLaunch(_context)) - { - var multiInstanceBehavior = LoadMultiInstanceLaunchBehavior(); - var existingShellStatus = await TryGetExistingHostStatusAsync(ipcClient, StartupTimeoutPolicy.ExistingHostProbeTimeout) - .ConfigureAwait(false); - if (HostActivationPolicy.IsExistingHostReadyForLauncherDecision(existingShellStatus)) - { - ipcConnected = true; - shellStatus = existingShellStatus; - var decisionResult = await ApplyExistingHostBehaviorAsync( - ipcClient, - multiInstanceBehavior, - existingShellStatus!) - .ConfigureAwait(false); - shellStatus = decisionResult.ActivationResult?.Status ?? existingShellStatus; - var recoverableActivationFailure = decisionResult.ActivationResult is not null && - HostActivationPolicy.IsRecoverableActivationFailure(decisionResult.ActivationResult); - lastStage = decisionResult.Success || recoverableActivationFailure - ? StartupStage.ActivationRedirected - : StartupStage.ActivationFailed; - lastStageMessage = decisionResult.Message; - if (decisionResult.Success || recoverableActivationFailure) - { - _startupAttemptRegistry.MarkOwnedSucceeded(lastStage, lastStageMessage); - } - else - { - _startupAttemptRegistry.MarkOwnedFailed(lastStage, lastStageMessage); - } - - PublishCoordinatorStatus(hostProcessAliveOverride: true, completed: true, succeeded: decisionResult.Success); - windowsClosingByCoordinator = true; - await CloseWindowsAsync(splashWindow, loadingDetailsWindow).ConfigureAwait(false); - return BuildResult( - success: decisionResult.Success, - stage: "launch", - code: decisionResult.Code, - message: decisionResult.Message, - details: MergeDetails( - launcherContextDetails, - new Dictionary(StringComparer.OrdinalIgnoreCase) - { - ["publicIpcConnected"] = "true", - ["multiInstanceBehavior"] = multiInstanceBehavior.ToString(), - ["existingHostPid"] = shellStatus?.ProcessId.ToString() ?? string.Empty, - ["existingShellState"] = shellStatus?.ShellState ?? string.Empty, - ["existingTrayState"] = shellStatus?.Tray.State ?? string.Empty, - ["existingTaskbarUsable"] = shellStatus?.Taskbar.IsUsable.ToString() ?? string.Empty, - ["activationAccepted"] = decisionResult.ActivationResult?.Accepted.ToString() ?? string.Empty - })); - } - } - - reporter.Report("update", "Checking updates..."); - var updateResult = await _updateEngine.ApplyPendingUpdateAsync().ConfigureAwait(false); - if (!updateResult.Success) - { - Logger.Warn($"Update apply failed, will try to launch existing version. Error='{updateResult.Message}'."); - reporter.Report("update", "Update failed, launching existing version..."); - // Clean up corrupted update files to prevent repeated failures - try - { - _updateEngine.CleanupIncomingArtifacts(); - } - catch (Exception ex) - { - Logger.Warn($"Failed to cleanup update artifacts after failed update: {ex.Message}"); - } - // Continue to launch existing version instead of aborting - } - - if (oobeDecision.ShouldShowOobe) - { - await Dispatcher.UIThread.InvokeAsync(() => splashWindow.Hide()); - foreach (var step in _oobeSteps) - { - await step.RunAsync(CancellationToken.None).ConfigureAwait(false); - } - - await Dispatcher.UIThread.InvokeAsync(() => splashWindow.Show()); - } - - reporter.Report("launch", "Launching desktop..."); - var launchOutcome = default(HostLaunchOutcome); - var attachableAttempt = _startupAttemptRegistry.TryGetAttachableAttempt(_context.LaunchSource, startupSuccessTracker.PolicyKey); - if (attachableAttempt is not null && - _startupAttemptRegistry.AdoptAttempt(attachableAttempt.AttemptId) && - TryGetLiveProcess(attachableAttempt.HostPid, out var attachedProcess)) - { - trackedAttempt = attachableAttempt; - attachedToExistingAttempt = true; - ipcConnected = attachableAttempt.IpcConnected; - lastStage = attachableAttempt.LastObservedStage; - lastStageMessage = string.IsNullOrWhiteSpace(attachableAttempt.LastObservedMessage) - ? "Attached to the existing startup attempt." - : attachableAttempt.LastObservedMessage; - reporter.Report(MapStartupStageToSplashStage(lastStage), lastStageMessage); - PublishCoordinatorStatus(hostProcessAliveOverride: true); - - if (startupSuccessTracker.TryResolve(lastStage, out var attachedSuccessState)) - { - windowsClosingByCoordinator = true; - _startupAttemptRegistry.MarkOwnedSucceeded(attachedSuccessState.Stage, attachedSuccessState.Message); - await CloseWindowsAsync(splashWindow, loadingDetailsWindow).ConfigureAwait(false); - return BuildResult( - success: true, - stage: "launch", - code: attachedSuccessState.Code, - message: attachedSuccessState.Message, - details: MergeDetails( - launcherContextDetails, - BuildAttemptDetails( - trackedAttempt, - attachedToExistingAttempt, - ipcConnected, - hostProcessAlive: true, - lastStage, - lastStageMessage, - activationFailureReason, - softTimeoutShown: false, - recoveryActivationAttempted: false))); - } - - if (attachableAttempt.State is StartupAttemptState.SoftTimeout or StartupAttemptState.DetachedWaiting) - { - softTimeoutShown = true; - reporter.Report("delayed", SoftTimeoutStatusMessage); - loadingState = HostStartupMonitor.BuildDelayedLoadingState( - loadingState, - SoftTimeoutStatusMessage, - SoftTimeoutDetailsMessage, - trackedAttempt.StartedAtUtc); - loadingDetailsWindow?.UpdateLoadingState(loadingState); - } - - launchOutcome = HostLaunchOutcome.FromProcess( - attachedProcess!, - BuildResult( - true, - "launchHost", - "attached_attempt", - "Attached to an existing startup attempt.", - BuildAttemptDetails( - trackedAttempt, - attachedToExistingAttempt, - ipcConnected, - hostProcessAlive: true, - lastStage, - lastStageMessage, - activationFailureReason, - softTimeoutShown, - recoveryActivationAttempted: false)), - BuildAttemptDetails( - trackedAttempt, - attachedToExistingAttempt, - ipcConnected, - hostProcessAlive: true, - lastStage, - lastStageMessage, - activationFailureReason, - softTimeoutShown, - recoveryActivationAttempted: false)); - } - else - { - launchOutcome = await LaunchHostWithIpcAsync().ConfigureAwait(false); - } - - if (!launchOutcome.Result.Success) - { - return WithAdditionalDetails(launchOutcome.Result, launcherContextDetails); - } - - if (launchOutcome.ImmediateResult is not null) - { - await CloseWindowsAsync(splashWindow, loadingDetailsWindow).ConfigureAwait(false); - return WithAdditionalDetails(launchOutcome.ImmediateResult, launcherContextDetails); - } - - if (launchOutcome.Process is null) - { - return BuildResult( - success: false, - stage: "launch", - code: "host_start_failed", - message: "Host launch did not create a process.", - details: MergeDetails( - launcherContextDetails, - MergeDetails( - launchOutcome.Details, - BuildAttemptDetails( - trackedAttempt, - attachedToExistingAttempt, - ipcConnected, - hostProcessAlive: false, - lastStage, - lastStageMessage, - activationFailureReason, - softTimeoutShown, - recoveryActivationAttempted: false)))); - } - - if (!attachedToExistingAttempt) - { - var reservedAttempt = _startupAttemptRegistry.GetOwnedAttempt(); - trackedAttempt = reservedAttempt is { ReservedBeforeHostStart: true } - ? _startupAttemptRegistry.AssignOwnedHostProcess( - launchOutcome.Process.Id, - lastStage, - lastStageMessage) - : _startupAttemptRegistry.StartOwnedAttempt( - launchOutcome.Process.Id, - _context.LaunchSource, - startupSuccessTracker.PolicyKey, - lastStage, - lastStageMessage); - PublishCoordinatorStatus(hostProcessAliveOverride: true); - } - - Dictionary ComposeLaunchDetails(bool hostProcessAlive, bool recoveryActivationAttempted = false) - { - return MergeDetails( - launcherContextDetails, - MergeDetails( - launchOutcome.Details, - BuildAttemptDetails( - trackedAttempt, - attachedToExistingAttempt, - ipcConnected, - hostProcessAlive, - lastStage, - lastStageMessage, - activationFailureReason, - softTimeoutShown, - recoveryActivationAttempted))); - } - - var monitor = new HostStartupMonitor(); - var monitorOutcome = await monitor.MonitorUntilCompleteAsync(new HostStartupMonitor.Request( - launchOutcome.Process, - ipcClient, - startupSuccessTracker, - _startupAttemptRegistry, - trackedAttempt, - attachedToExistingAttempt, - launcherContextDetails, - successTcs, - activationFailedTcs, - reporter, - loadingDetailsWindow, - loadingState, - lastStage, - lastStageMessage, - ipcConnected, - activationFailureReason, - softTimeoutShown, - (hostProcessAliveOverride, completed, succeeded) => - PublishCoordinatorStatus(hostProcessAliveOverride, completed, succeeded), - ComposeLaunchDetails)).ConfigureAwait(false); - - windowsClosingByCoordinator = true; - await CloseWindowsAsync(splashWindow, loadingDetailsWindow).ConfigureAwait(false); - return BuildResult( - success: monitorOutcome.Success, - stage: "launch", - code: monitorOutcome.Code, - message: monitorOutcome.Message, - details: monitorOutcome.Details); - } - finally - { - if (splashClosedHandler is not null) - { - splashWindow.Closed -= splashClosedHandler; - } - - if (!windowsClosingByCoordinator) - { - await Dispatcher.UIThread.InvokeAsync(() => - { - try - { - if (splashWindow.IsVisible && splashWindow.IsLoaded) - { - splashWindow.Close(); - Logger.Info("Splash window closed in coordinator cleanup."); - } - } - catch (Exception ex) - { - Logger.Error("Failed to close splash window during coordinator cleanup.", ex); - } - }); - } - } - } - catch (Exception ex) - { - Logger.Error("Launcher coordinator failed.", ex); - return BuildResult( - success: false, - stage: "launch", - code: "exception", - message: ex.Message, - details: BuildLauncherContextDetails(_context, _oobeStateService.Evaluate(_context), _deploymentLocator.GetAppRoot()), - errorMessage: ex.ToString()); - } - } - - - - - - private static LauncherResult BuildResult( - 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 ?? [] - }; - } - - private static LauncherResult WithAdditionalDetails(LauncherResult result, Dictionary details) - { - return new LauncherResult - { - 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 - }; - } - - private static Dictionary BuildLauncherContextDetails( - CommandContext context, - OobeLaunchDecision oobeDecision, - string appRoot) - { - return new Dictionary(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 - }; - } - - - private 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; - } -} - diff --git a/LanMountainDesktop.Launcher/Shell/LauncherCompositionRoot.cs b/LanMountainDesktop.Launcher/Shell/LauncherCompositionRoot.cs index dca4f6d..74e2fec 100644 --- a/LanMountainDesktop.Launcher/Shell/LauncherCompositionRoot.cs +++ b/LanMountainDesktop.Launcher/Shell/LauncherCompositionRoot.cs @@ -19,17 +19,8 @@ internal static class LauncherCompositionRoot 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); - } + LauncherCoordinatorIpcServer coordinatorServer) => + LauncherServiceRegistration.CreateOrchestrator(context, startupAttemptRegistry, coordinatorServer); public static async Task RunOrchestratorWithSplashAsync( IClassicDesktopStyleApplicationLifetime desktop, @@ -161,7 +152,7 @@ internal static class LauncherCompositionRoot { var appRoot = Commands.ResolveAppRoot(context); var deploymentLocator = new DeploymentLocator(appRoot); - var updateEngine = new UpdateEngineService(deploymentLocator); + var updateEngine = new UpdateEngineFacade(deploymentLocator); var pluginInstaller = new PluginInstallerService(); var pluginUpgrades = new PluginUpgradeQueueService(pluginInstaller); diff --git a/LanMountainDesktop.Launcher/Shell/LauncherOrchestrator.cs b/LanMountainDesktop.Launcher/Shell/LauncherOrchestrator.cs index fc1ac0e..2cff63f 100644 --- a/LanMountainDesktop.Launcher/Shell/LauncherOrchestrator.cs +++ b/LanMountainDesktop.Launcher/Shell/LauncherOrchestrator.cs @@ -13,7 +13,7 @@ internal sealed class LauncherOrchestrator private readonly CommandContext _context; private readonly DeploymentLocator _deploymentLocator; private readonly OobeStateService _oobeStateService; - private readonly UpdateEngineService _updateEngine; + private readonly IUpdateEngine _updateEngine; private readonly StartupAttemptRegistry _startupAttemptRegistry; private readonly LauncherCoordinatorIpcServer? _coordinatorIpcServer; private readonly DataLocationResolver _dataLocationResolver; @@ -24,9 +24,10 @@ internal sealed class LauncherOrchestrator CommandContext context, DeploymentLocator deploymentLocator, OobeStateService oobeStateService, - UpdateEngineService updateEngine, + IUpdateEngine updateEngine, StartupAttemptRegistry startupAttemptRegistry, - LauncherCoordinatorIpcServer? coordinatorIpcServer = null) + LauncherCoordinatorIpcServer? coordinatorIpcServer = null, + LaunchPipeline? pipeline = null) { _context = context; _deploymentLocator = deploymentLocator; @@ -40,7 +41,7 @@ internal sealed class LauncherOrchestrator new WelcomeOobeStep(_oobeStateService, _context), new DataLocationOobeStep(_dataLocationResolver) ]; - _pipeline = new LaunchPipeline( + _pipeline = pipeline ?? new LaunchPipeline( [ new CleanupDeploymentsPhase(), new ExistingHostProbePhase(), diff --git a/LanMountainDesktop.Launcher/Shell/LauncherServiceRegistration.cs b/LanMountainDesktop.Launcher/Shell/LauncherServiceRegistration.cs new file mode 100644 index 0000000..abe8218 --- /dev/null +++ b/LanMountainDesktop.Launcher/Shell/LauncherServiceRegistration.cs @@ -0,0 +1,55 @@ +using Microsoft.Extensions.DependencyInjection; + +namespace LanMountainDesktop.Launcher.Shell; + +internal static class LauncherServiceRegistration +{ + private static ServiceProvider? _provider; + + public static IServiceProvider Provider => + _provider ?? throw new InvalidOperationException("Launcher services are not initialized."); + + public static void Initialize(CommandContext context) + { + if (_provider is not null) + { + return; + } + + var appRoot = Commands.ResolveAppRoot(context); + var services = new ServiceCollection(); + services.AddSingleton(context); + services.AddSingleton(new DeploymentLocator(appRoot)); + services.AddSingleton(sp => new OobeStateService(appRoot)); + services.AddSingleton(sp => new DataLocationResolver(appRoot)); + services.AddSingleton(sp => new UpdateEngineFacade(sp.GetRequiredService())); + services.AddSingleton(); + services.AddSingleton(); + services.AddSingleton(); + services.AddSingleton(); + services.AddSingleton(); + services.AddSingleton(); + services.AddSingleton(); + services.AddSingleton(); + services.AddSingleton(sp => new LaunchPipeline(sp.GetServices())); + + _provider = services.BuildServiceProvider(); + } + + public static LauncherOrchestrator CreateOrchestrator( + CommandContext context, + StartupAttemptRegistry startupAttemptRegistry, + LauncherCoordinatorIpcServer coordinatorServer) + { + Initialize(context); + var services = Provider; + return new LauncherOrchestrator( + context, + services.GetRequiredService(), + services.GetRequiredService(), + services.GetRequiredService(), + startupAttemptRegistry, + coordinatorServer, + services.GetRequiredService()); + } +} diff --git a/LanMountainDesktop.Launcher/Startup/LaunchPipeline.cs b/LanMountainDesktop.Launcher/Startup/LaunchPipeline.cs index 1748c40..65212e3 100644 --- a/LanMountainDesktop.Launcher/Startup/LaunchPipeline.cs +++ b/LanMountainDesktop.Launcher/Startup/LaunchPipeline.cs @@ -27,7 +27,7 @@ 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 IUpdateEngine UpdateEngine { get; init; } public required StartupAttemptRegistry StartupAttemptRegistry { get; init; } public LauncherCoordinatorIpcServer? CoordinatorIpcServer { get; init; } public required DataLocationResolver DataLocationResolver { get; init; } diff --git a/LanMountainDesktop.Launcher/Update/IUpdateEngine.cs b/LanMountainDesktop.Launcher/Update/IUpdateEngine.cs new file mode 100644 index 0000000..2d1204d --- /dev/null +++ b/LanMountainDesktop.Launcher/Update/IUpdateEngine.cs @@ -0,0 +1,18 @@ +using LanMountainDesktop.Launcher.Models; + +namespace LanMountainDesktop.Launcher.Update; + +internal interface IUpdateEngine +{ + LauncherResult CheckPendingUpdate(); + + Task DownloadAsync(string manifestUrl, string signatureUrl, string archiveUrl, CancellationToken cancellationToken); + + Task ApplyPendingUpdateAsync(); + + LauncherResult RollbackLatest(); + + void CleanupDestroyedDeployments(); + + void CleanupIncomingArtifacts(); +} diff --git a/LanMountainDesktop.Launcher/Update/UpdateEngineService.cs b/LanMountainDesktop.Launcher/Update/UpdateEngineFacade.cs similarity index 99% rename from LanMountainDesktop.Launcher/Update/UpdateEngineService.cs rename to LanMountainDesktop.Launcher/Update/UpdateEngineFacade.cs index a6d358f..77cc39d 100644 --- a/LanMountainDesktop.Launcher/Update/UpdateEngineService.cs +++ b/LanMountainDesktop.Launcher/Update/UpdateEngineFacade.cs @@ -6,7 +6,7 @@ using ContractsUpdate = LanMountainDesktop.Shared.Contracts.Update; namespace LanMountainDesktop.Launcher.Update; -internal sealed class UpdateEngineService +internal sealed class UpdateEngineFacade : IUpdateEngine { private const string UpdateDirectoryName = "update"; private const string IncomingDirectoryName = "incoming"; @@ -28,7 +28,7 @@ internal sealed class UpdateEngineService private readonly string _snapshotsRoot; private readonly string _installCheckpointPath; - public UpdateEngineService(DeploymentLocator deploymentLocator, IUpdateProgressReporter? progressReporter = null) + public UpdateEngineFacade(DeploymentLocator deploymentLocator, IUpdateProgressReporter? progressReporter = null) { _deploymentLocator = deploymentLocator; _progressReporter = progressReporter ?? new NullUpdateProgressReporter(); @@ -1674,7 +1674,7 @@ internal sealed class UpdateEngineService private sealed record RollbackAttemptResult(bool Success, string? ErrorMessage); - internal void CleanupIncomingArtifacts() + public void CleanupIncomingArtifacts() { foreach (var path in new[] { diff --git a/LanMountainDesktop.Launcher/Views/DataLocationPromptWindow.axaml.cs b/LanMountainDesktop.Launcher/Views/DataLocationPromptWindow.axaml.cs index 57b1ad7..af6af4b 100644 --- a/LanMountainDesktop.Launcher/Views/DataLocationPromptWindow.axaml.cs +++ b/LanMountainDesktop.Launcher/Views/DataLocationPromptWindow.axaml.cs @@ -8,7 +8,7 @@ using Avalonia.Media; using Avalonia.Styling; using LanMountainDesktop.Launcher.Models; using LanMountainDesktop.Launcher.Resources; -using LanMountainDesktop.Launcher.Services; +using LanMountainDesktop.Launcher.Infrastructure; namespace LanMountainDesktop.Launcher.Views; diff --git a/LanMountainDesktop.Launcher/Views/DevDebugWindow.axaml.cs b/LanMountainDesktop.Launcher/Views/DevDebugWindow.axaml.cs index 4baf4c8..5de8daa 100644 --- a/LanMountainDesktop.Launcher/Views/DevDebugWindow.axaml.cs +++ b/LanMountainDesktop.Launcher/Views/DevDebugWindow.axaml.cs @@ -1,7 +1,7 @@ using Avalonia.Controls; using Avalonia.Markup.Xaml; using LanMountainDesktop.Launcher.Resources; -using LanMountainDesktop.Launcher.Services; +using LanMountainDesktop.Launcher.Infrastructure; using LanMountainDesktop.Launcher.ViewModels; using LanMountainDesktop.Launcher.Views; diff --git a/LanMountainDesktop.Launcher/Views/ErrorWindow.axaml.cs b/LanMountainDesktop.Launcher/Views/ErrorWindow.axaml.cs index 28af97e..d4a4b6f 100644 --- a/LanMountainDesktop.Launcher/Views/ErrorWindow.axaml.cs +++ b/LanMountainDesktop.Launcher/Views/ErrorWindow.axaml.cs @@ -6,7 +6,7 @@ using Avalonia.Interactivity; using Avalonia.Markup.Xaml; using FluentAvalonia.UI.Controls; using LanMountainDesktop.Launcher.Resources; -using LanMountainDesktop.Launcher.Services; +using LanMountainDesktop.Launcher.Infrastructure; namespace LanMountainDesktop.Launcher.Views; diff --git a/LanMountainDesktop.Launcher/Views/LoadingDetailsWindow.axaml.cs b/LanMountainDesktop.Launcher/Views/LoadingDetailsWindow.axaml.cs index ae543e5..ed5ab06 100644 --- a/LanMountainDesktop.Launcher/Views/LoadingDetailsWindow.axaml.cs +++ b/LanMountainDesktop.Launcher/Views/LoadingDetailsWindow.axaml.cs @@ -4,7 +4,7 @@ using Avalonia.Markup.Xaml; using Avalonia.Media; using Avalonia.Threading; using LanMountainDesktop.Launcher.Resources; -using LanMountainDesktop.Launcher.Services; +using LanMountainDesktop.Launcher.Infrastructure; using LanMountainDesktop.Shared.Contracts.Launcher; using System.Collections.ObjectModel; using System.ComponentModel; diff --git a/LanMountainDesktop.Launcher/Views/MigrationPromptWindow.axaml.cs b/LanMountainDesktop.Launcher/Views/MigrationPromptWindow.axaml.cs index e7cf2fc..b1e76aa 100644 --- a/LanMountainDesktop.Launcher/Views/MigrationPromptWindow.axaml.cs +++ b/LanMountainDesktop.Launcher/Views/MigrationPromptWindow.axaml.cs @@ -2,7 +2,7 @@ using Avalonia.Controls; using Avalonia.Interactivity; using Avalonia.Markup.Xaml; using LanMountainDesktop.Launcher.Resources; -using LanMountainDesktop.Launcher.Services; +using LanMountainDesktop.Launcher.Infrastructure; namespace LanMountainDesktop.Launcher.Views; diff --git a/LanMountainDesktop.Launcher/Views/OobeWindow.axaml.cs b/LanMountainDesktop.Launcher/Views/OobeWindow.axaml.cs index b3fd702..d98adee 100644 --- a/LanMountainDesktop.Launcher/Views/OobeWindow.axaml.cs +++ b/LanMountainDesktop.Launcher/Views/OobeWindow.axaml.cs @@ -8,7 +8,7 @@ using Avalonia.Media; using Avalonia.Threading; using LanMountainDesktop.Launcher.Models; using LanMountainDesktop.Launcher.Resources; -using LanMountainDesktop.Launcher.Services; +using LanMountainDesktop.Launcher.Infrastructure; namespace LanMountainDesktop.Launcher.Views; diff --git a/LanMountainDesktop.Launcher/Views/SplashWindow.axaml.cs b/LanMountainDesktop.Launcher/Views/SplashWindow.axaml.cs index b9f4c7e..a67f607 100644 --- a/LanMountainDesktop.Launcher/Views/SplashWindow.axaml.cs +++ b/LanMountainDesktop.Launcher/Views/SplashWindow.axaml.cs @@ -7,7 +7,7 @@ using Avalonia.Markup.Xaml; using Avalonia.Media; using Avalonia.Threading; using LanMountainDesktop.Launcher.Resources; -using LanMountainDesktop.Launcher.Services; +using LanMountainDesktop.Launcher.Infrastructure; namespace LanMountainDesktop.Launcher.Views; diff --git a/LanMountainDesktop.Tests/DeploymentLocatorTests.cs b/LanMountainDesktop.Tests/DeploymentLocatorTests.cs index 5a5f09b..2817e7f 100644 --- a/LanMountainDesktop.Tests/DeploymentLocatorTests.cs +++ b/LanMountainDesktop.Tests/DeploymentLocatorTests.cs @@ -1,5 +1,4 @@ using LanMountainDesktop.Launcher; -using LanMountainDesktop.Launcher.Services; using Xunit; namespace LanMountainDesktop.Tests; diff --git a/LanMountainDesktop.Tests/DotNetRuntimeProbeTests.cs b/LanMountainDesktop.Tests/DotNetRuntimeProbeTests.cs index 6f2f64d..c44cde2 100644 --- a/LanMountainDesktop.Tests/DotNetRuntimeProbeTests.cs +++ b/LanMountainDesktop.Tests/DotNetRuntimeProbeTests.cs @@ -1,4 +1,3 @@ -using LanMountainDesktop.Launcher.Services; using Xunit; namespace LanMountainDesktop.Tests; @@ -192,7 +191,7 @@ public sealed class DotNetRuntimeProbeTests : IDisposable SearchedPaths = [hostPath] }; - var result = LauncherFlowCoordinator.ValidateDotNetRuntimePrerequisite( + var result = HostLaunchService.ValidateDotNetRuntimePrerequisite( plan, resolution, CreateOptions(DotNetRuntimeArchitecture.X64)); diff --git a/LanMountainDesktop.Tests/HostAppSettingsOobeMergerTests.cs b/LanMountainDesktop.Tests/HostAppSettingsOobeMergerTests.cs index a16a20e..a4b3c29 100644 --- a/LanMountainDesktop.Tests/HostAppSettingsOobeMergerTests.cs +++ b/LanMountainDesktop.Tests/HostAppSettingsOobeMergerTests.cs @@ -1,4 +1,3 @@ -using LanMountainDesktop.Launcher.Services; using LanMountainDesktop.Shared.Contracts.Launcher; using Xunit; diff --git a/LanMountainDesktop.Tests/HostLaunchPlanBuilderTests.cs b/LanMountainDesktop.Tests/HostLaunchPlanBuilderTests.cs index 38a4ae1..4e293e9 100644 --- a/LanMountainDesktop.Tests/HostLaunchPlanBuilderTests.cs +++ b/LanMountainDesktop.Tests/HostLaunchPlanBuilderTests.cs @@ -1,5 +1,4 @@ using LanMountainDesktop.Launcher; -using LanMountainDesktop.Launcher.Services; using LanMountainDesktop.Shared.Contracts.Launcher; using Xunit; diff --git a/LanMountainDesktop.Tests/LauncherArchitectureTests.cs b/LanMountainDesktop.Tests/LauncherArchitectureTests.cs new file mode 100644 index 0000000..ad1f4c2 --- /dev/null +++ b/LanMountainDesktop.Tests/LauncherArchitectureTests.cs @@ -0,0 +1,41 @@ +using System.Reflection; +using Xunit; + +namespace LanMountainDesktop.Tests; + +public sealed class LauncherArchitectureTests +{ + private static readonly string LauncherAssemblyName = "LanMountainDesktop.Launcher"; + + [Fact] + public void Deployment_Update_Startup_Infrastructure_DoNotReferenceAvalonia() + { + var forbidden = new[] { "Deployment", "Update", "Startup", "Infrastructure" }; + foreach (var nsSuffix in forbidden) + { + var types = GetLauncherTypes($"LanMountainDesktop.Launcher.{nsSuffix}"); + var assembly = types.First().Assembly; + Assert.DoesNotContain( + assembly.GetReferencedAssemblies(), + a => string.Equals(a.Name, "Avalonia", StringComparison.OrdinalIgnoreCase)); + } + } + + [Fact] + public void LauncherFlowCoordinator_TypeDoesNotExist() + { + var coordinator = typeof(LanMountainDesktop.Launcher.Shell.LauncherOrchestrator).Assembly + .GetType("LanMountainDesktop.Launcher.Services.LauncherFlowCoordinator", throwOnError: false); + Assert.Null(coordinator); + } + + private static IEnumerable GetLauncherTypes(string namespacePrefix) + { + var assembly = AppDomain.CurrentDomain.GetAssemblies() + .FirstOrDefault(a => string.Equals(a.GetName().Name, LauncherAssemblyName, StringComparison.OrdinalIgnoreCase)) + ?? throw new InvalidOperationException("Launcher assembly not loaded."); + + return assembly.GetTypes() + .Where(t => t.Namespace is not null && t.Namespace.StartsWith(namespacePrefix, StringComparison.Ordinal)); + } +} diff --git a/LanMountainDesktop.Tests/LauncherCoordinatorRegistryTests.cs b/LanMountainDesktop.Tests/LauncherCoordinatorRegistryTests.cs index 7146c6c..34e6912 100644 --- a/LanMountainDesktop.Tests/LauncherCoordinatorRegistryTests.cs +++ b/LanMountainDesktop.Tests/LauncherCoordinatorRegistryTests.cs @@ -1,6 +1,5 @@ using System.Text.Json.Nodes; using LanMountainDesktop.Launcher.Models; -using LanMountainDesktop.Launcher.Services; using LanMountainDesktop.Shared.Contracts.Launcher; using Xunit; diff --git a/LanMountainDesktop.Tests/LauncherDebugSettingsStoreTests.cs b/LanMountainDesktop.Tests/LauncherDebugSettingsStoreTests.cs index 305b481..559db54 100644 --- a/LanMountainDesktop.Tests/LauncherDebugSettingsStoreTests.cs +++ b/LanMountainDesktop.Tests/LauncherDebugSettingsStoreTests.cs @@ -1,4 +1,3 @@ -using LanMountainDesktop.Launcher.Services; using Xunit; namespace LanMountainDesktop.Tests; diff --git a/LanMountainDesktop.Tests/OobeStateServiceTests.cs b/LanMountainDesktop.Tests/OobeStateServiceTests.cs index 5729c1a..395b5be 100644 --- a/LanMountainDesktop.Tests/OobeStateServiceTests.cs +++ b/LanMountainDesktop.Tests/OobeStateServiceTests.cs @@ -1,7 +1,6 @@ using System.Text.Json; using LanMountainDesktop.Launcher; using LanMountainDesktop.Launcher.Models; -using LanMountainDesktop.Launcher.Services; using Xunit; namespace LanMountainDesktop.Tests; diff --git a/LanMountainDesktop.Tests/UpdateSystemRegressionTests.cs b/LanMountainDesktop.Tests/UpdateSystemRegressionTests.cs index beda754..54096e1 100644 --- a/LanMountainDesktop.Tests/UpdateSystemRegressionTests.cs +++ b/LanMountainDesktop.Tests/UpdateSystemRegressionTests.cs @@ -5,7 +5,6 @@ using System.Text.Json; using LanMountainDesktop; using LanMountainDesktop.Launcher; using LanMountainDesktop.Launcher.Models; -using LanMountainDesktop.Launcher.Services; using LanMountainDesktop.Services; using LanMountainDesktop.Services.Update; using LanMountainDesktop.Shared.Contracts.Update; @@ -25,7 +24,7 @@ public sealed class UpdateEngineRollbackRegressionTests : IDisposable _directory.StagePlondsUpdate("1.0.0", "1.1.0", newState, Sha256Hex(newState)); - var service = new UpdateEngineService(new DeploymentLocator(_directory.AppRoot)); + var service = new UpdateEngineFacade(new DeploymentLocator(_directory.AppRoot)); var result = await service.ApplyPendingUpdateAsync(); Assert.True(result.Success, result.ErrorMessage); @@ -49,7 +48,7 @@ public sealed class UpdateEngineRollbackRegressionTests : IDisposable _directory.StagePlondsUpdate("1.0.0", "1.1.0", newState, new string('0', 64)); - var service = new UpdateEngineService(new DeploymentLocator(_directory.AppRoot)); + var service = new UpdateEngineFacade(new DeploymentLocator(_directory.AppRoot)); var result = await service.ApplyPendingUpdateAsync(); Assert.False(result.Success); @@ -71,7 +70,7 @@ public sealed class UpdateEngineRollbackRegressionTests : IDisposable targetVersion: "1.1.0", targetDirectory: Path.Combine(_directory.AppRoot, "app-1.1.0-0")); - var service = new UpdateEngineService(new DeploymentLocator(_directory.AppRoot)); + var service = new UpdateEngineFacade(new DeploymentLocator(_directory.AppRoot)); var result = service.RollbackLatest(); Assert.False(result.Success); @@ -87,7 +86,7 @@ public sealed class UpdateEngineRollbackRegressionTests : IDisposable _directory.StagePlondsUpdate("1.0.0", "1.1.0", newState, Sha256Hex(newState)); _directory.WriteStaleInstallCheckpoint("9.9.9", "1.1.0"); - var service = new UpdateEngineService(new DeploymentLocator(_directory.AppRoot)); + var service = new UpdateEngineFacade(new DeploymentLocator(_directory.AppRoot)); var result = await service.ApplyPendingUpdateAsync(); Assert.False(result.Success); @@ -101,7 +100,7 @@ public sealed class UpdateEngineRollbackRegressionTests : IDisposable _directory.StageLegacyUpdate("1.0.0", "1.1.0", "new-state"); _directory.WriteStaleInstallCheckpoint("9.9.9", "1.1.0"); - var service = new UpdateEngineService(new DeploymentLocator(_directory.AppRoot)); + var service = new UpdateEngineFacade(new DeploymentLocator(_directory.AppRoot)); var result = await service.ApplyPendingUpdateAsync(); Assert.False(result.Success); @@ -116,7 +115,7 @@ public sealed class UpdateEngineRollbackRegressionTests : IDisposable _directory.StagePlondsUpdate("1.0.0", "1.1.0", newState, Sha256Hex(newState)); _directory.WriteValidPlondsResumeCheckpoint("1.0.0", "1.1.0"); - var service = new UpdateEngineService(new DeploymentLocator(_directory.AppRoot)); + var service = new UpdateEngineFacade(new DeploymentLocator(_directory.AppRoot)); var result = await service.ApplyPendingUpdateAsync(); Assert.True(result.Success, result.ErrorMessage); @@ -135,7 +134,7 @@ public sealed class UpdateEngineRollbackRegressionTests : IDisposable _directory.StageLegacyUpdate("1.0.0", "1.1.0", "new-state"); _directory.WriteValidLegacyResumeCheckpoint("1.0.0", "1.1.0"); - var service = new UpdateEngineService(new DeploymentLocator(_directory.AppRoot)); + var service = new UpdateEngineFacade(new DeploymentLocator(_directory.AppRoot)); var result = await service.ApplyPendingUpdateAsync(); Assert.True(result.Success, result.ErrorMessage); diff --git a/docs/LAUNCHER.md b/docs/LAUNCHER.md index 5c304b9..9bad949 100644 --- a/docs/LAUNCHER.md +++ b/docs/LAUNCHER.md @@ -141,42 +141,34 @@ Task CheckForUpdateAsync( - `Stable` - 只检查 `prerelease=false` 的版本 - `Preview` - 检查所有版本 (包括 `prerelease=true`) -### UpdateEngineService -**职责**: 下载、验证、应用更新 +### IUpdateEngine / UpdateEngineFacade +**职责**: 下载、验证、应用更新(实现位于 `Update/UpdateEngineFacade.cs`,契约 `Update/IUpdateEngine.cs`) **关键方法**: ```csharp -// 检查待处理的更新 LauncherResult CheckPendingUpdate() - -// 下载更新 -Task DownloadAsync( - string manifestUrl, - string signatureUrl, - string archiveUrl, - CancellationToken cancellationToken) - -// 应用待处理的更新 -LauncherResult ApplyPendingUpdate() - -// 回退到上一个版本 +Task DownloadAsync(...) +Task ApplyPendingUpdateAsync() LauncherResult RollbackLatest() - -// 清理待删除的部署 void CleanupDestroyedDeployments() +void CleanupIncomingArtifacts() ``` -### LauncherFlowCoordinator -**职责**: 协调完整的启动流程 +### LauncherOrchestrator / LaunchPipeline +**职责**: 协调完整的启动流程(`Shell/LauncherOrchestrator.cs` + `Startup/LaunchPipeline.cs`) -**启动流程**: -1. 清理待删除的旧版本 -2. 检查是否首次运行,显示 OOBE -3. 显示 Splash 窗口 -4. 应用待处理的更新 -5. 处理插件升级队列 -6. 启动主程序 -7. 关闭 Splash 窗口 +**启动阶段 (ILaunchPhase)**: +1. `CleanupDeploymentsPhase` — 清理旧部署 +2. `ExistingHostProbePhase` — 多实例 / 现有 Host 探测 +3. `ApplyPendingUpdatePhase` — 应用 pending 更新 +4. `OobeGatePhase` — OOBE 步骤 +5. `LaunchHostPhase` — 启动 Host +6. `MonitorStartupPhase` — IPC 启动监控 + +**GUI 入口**: `Shell/LauncherCompositionRoot` + `Shell/LauncherServiceRegistration`(MS DI 轻量装配) + +### ~~LauncherFlowCoordinator~~ (已移除) +已由 `LauncherOrchestrator` + `LaunchPipeline` 替代。 ### OobeStateService **职责**: 管理首次运行状态