refactor(launcher): replace LauncherFlowCoordinator with LaunchPipeline and slim App shell

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
lincube
2026-05-28 11:03:49 +08:00
parent b219f109ec
commit a26b6faace
19 changed files with 2530 additions and 801 deletions

View File

@@ -0,0 +1,27 @@
namespace LanMountainDesktop.Launcher.Startup;
internal sealed class ApplyPendingUpdatePhase : ILaunchPhase
{
public string Name => nameof(ApplyPendingUpdatePhase);
public async Task<LaunchPhaseResult> 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);
}
}

View File

@@ -0,0 +1,17 @@
namespace LanMountainDesktop.Launcher.Startup;
internal sealed class CleanupDeploymentsPhase : ILaunchPhase
{
public string Name => nameof(CleanupDeploymentsPhase);
public Task<LaunchPhaseResult> 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));
}
}

View File

@@ -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<LaunchPhaseResult> 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<string, string>(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
})));
}
}

View File

@@ -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<LaunchPhaseResult> 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);
}
}

View File

@@ -0,0 +1,68 @@
namespace LanMountainDesktop.Launcher.Startup;
internal sealed class MonitorStartupPhase : ILaunchPhase
{
public string Name => nameof(MonitorStartupPhase);
public async Task<LaunchPhaseResult> 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<string, string> 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));
}
}

View File

@@ -0,0 +1,24 @@
using Avalonia.Threading;
namespace LanMountainDesktop.Launcher.Startup;
internal sealed class OobeGatePhase : ILaunchPhase
{
public string Name => nameof(OobeGatePhase);
public async Task<LaunchPhaseResult> 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);
}
}